Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions PolyPilot.Tests/TestStubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ public Task RequestReposAsync(CancellationToken ct = default)
return Task.CompletedTask;
}

public Task<WorktreeCreatedPayload> CreateWorktreeAsync(string repoId, string? branchName, int? prNumber, CancellationToken ct = default)
=> Task.FromResult(new WorktreeCreatedPayload { RepoId = repoId, Branch = branchName ?? "main", Path = "/tmp/test" });
public Task RemoveWorktreeAsync(string worktreeId, CancellationToken ct = default) => Task.CompletedTask;

public Task<FetchImageResponsePayload> FetchImageAsync(string path, CancellationToken ct = default)
=> Task.FromResult(new FetchImageResponsePayload { Error = "Stub" });
}
Expand Down
35 changes: 31 additions & 4 deletions PolyPilot/Components/Layout/CreateSessionForm.razor
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@using PolyPilot.Models
@inject IJSRuntime JS
@inject RepoManager RepoManager
@inject CopilotService CopilotService

@if (!isExpanded)
{
Expand Down Expand Up @@ -275,8 +276,18 @@ else
isCreatingWorktree = true; worktreeError = null;
try
{
var wt = await RepoManager.CreateWorktreeFromPrAsync(newWorktreeRepoId, prNum);
SelectWorktree(wt);
if (CopilotService.IsRemoteMode)
{
var result = await CopilotService.CreateWorktreeViaBridgeAsync(newWorktreeRepoId, null, prNum);
var wt = new WorktreeInfo { Id = result.WorktreeId, RepoId = result.RepoId, Branch = result.Branch, Path = result.Path, PrNumber = result.PrNumber };
RepoManager.AddRemoteWorktree(wt);
SelectWorktree(wt);
}
else
{
var wt = await RepoManager.CreateWorktreeFromPrAsync(newWorktreeRepoId, prNum);
SelectWorktree(wt);
}
if (string.IsNullOrWhiteSpace(SessionName))
{ SessionName = $"PR #{prNum}"; await SessionNameChanged.InvokeAsync(SessionName); }
}
Expand All @@ -289,7 +300,17 @@ else
isCreatingWorktree = true; worktreeError = null;
try
{
var wt = await RepoManager.CreateWorktreeAsync(newWorktreeRepoId, newWorktreeBranch.Trim());
WorktreeInfo wt;
if (CopilotService.IsRemoteMode)
{
var result = await CopilotService.CreateWorktreeViaBridgeAsync(newWorktreeRepoId, newWorktreeBranch.Trim(), null);
wt = new WorktreeInfo { Id = result.WorktreeId, RepoId = result.RepoId, Branch = result.Branch, Path = result.Path, PrNumber = result.PrNumber };
RepoManager.AddRemoteWorktree(wt);
}
else
{
wt = await RepoManager.CreateWorktreeAsync(newWorktreeRepoId, newWorktreeBranch.Trim());
}
SelectWorktree(wt);
if (string.IsNullOrWhiteSpace(SessionName))
{ SessionName = wt.Branch; await SessionNameChanged.InvokeAsync(SessionName); }
Expand All @@ -302,7 +323,13 @@ else
private async Task RemoveWorktree(string worktreeId)
{
try
{ if (selectedWorktreeId == worktreeId) ClearWorktree(); await RepoManager.RemoveWorktreeAsync(worktreeId); }
{
if (selectedWorktreeId == worktreeId) ClearWorktree();
if (CopilotService.IsRemoteMode)
await CopilotService.RemoveWorktreeViaBridgeAsync(worktreeId);
else
await RepoManager.RemoveWorktreeAsync(worktreeId);
}
catch (Exception ex) { worktreeError = ex.Message; }
}

Expand Down
7 changes: 7 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@
<button class="menu-item destructive" @onclick="() => { OnCloseMenu.InvokeAsync(); OnClose.InvokeAsync(); }">
✕ Close Session
</button>
@if (!string.IsNullOrEmpty(Meta?.WorktreeId))
{
<button class="menu-item destructive" @onclick="() => { OnCloseMenu.InvokeAsync(); OnCloseAndDeleteWorktree.InvokeAsync(); }">
🗑 Close &amp; Delete Worktree
</button>
}
</div>
}
</div>
Expand All @@ -218,6 +224,7 @@

[Parameter] public EventCallback OnSelect { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnCloseAndDeleteWorktree { get; set; }
[Parameter] public EventCallback<bool> OnPin { get; set; }
[Parameter] public EventCallback<string> OnMove { get; set; }
[Parameter] public EventCallback OnStartRename { get; set; }
Expand Down
87 changes: 87 additions & 0 deletions PolyPilot/Components/Layout/SessionSidebar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,12 @@ else
<button class="group-menu-item destructive" @onclick="() => { openGroupMenuId = null; CopilotService.DeleteGroup(gId); }">
🗑 Delete Team
</button>
@if (!string.IsNullOrEmpty(group.WorktreeId))
{
<button class="group-menu-item destructive" @onclick="() => { openGroupMenuId = null; DeleteGroupAndWorktree(gId, group.WorktreeId); }">
🗑 Delete Team &amp; Worktree
</button>
}
}
<button class="group-menu-item destructive" @onclick="() => { openGroupMenuId = null; confirmRemoveRepoId = rId; }">
🗑 Remove Repo
Expand All @@ -449,6 +455,12 @@ else
<button class="group-menu-item destructive" @onclick="() => { openGroupMenuId = null; CopilotService.DeleteGroup(gId); }">
🗑 @(group.IsMultiAgent ? "Delete Team" : "Delete Group")
</button>
@if (!string.IsNullOrEmpty(group.WorktreeId))
{
<button class="group-menu-item destructive" @onclick="() => { openGroupMenuId = null; DeleteGroupAndWorktree(gId, group.WorktreeId); }">
🗑 @(group.IsMultiAgent ? "Delete Team" : "Delete Group") &amp; Worktree
</button>
}
}
</div>
}
Expand Down Expand Up @@ -544,6 +556,7 @@ else
UsageInfo="@(usageBySession.TryGetValue(session.Name, out var usg) ? usg : null)"
OnSelect="() => SelectSession(sName)"
OnClose="() => CloseSession(sName)"
OnCloseAndDeleteWorktree="() => CloseSessionAndDeleteWorktree(sName)"
OnPin="(pinned) => { CopilotService.PinSession(sName, pinned); }"
OnMove="(groupId) => CopilotService.MoveSession(sName, groupId)"
OnStartRename="() => StartRename(sName)"
Expand Down Expand Up @@ -1036,6 +1049,80 @@ else
await CopilotService.CloseSessionAsync(name);
}

private async Task CloseSessionAndDeleteWorktree(string name)
{
// Find the worktree associated with this session before closing
var meta = CopilotService.GetSessionMeta(name);
var worktreeId = meta?.WorktreeId;

// Close the session first
await CopilotService.CloseSessionAsync(name);

// Delete the worktree if no OTHER sessions or groups still reference it
// (exclude the just-closed session name since its metadata may linger due to debounced save)
if (!string.IsNullOrEmpty(worktreeId) && !IsWorktreeInUse(worktreeId, excludeSession: name))
{
await DeleteWorktreeAsync(worktreeId);
}
}

private async Task DeleteGroupAndWorktree(string groupId, string worktreeId)
{
// Collect session names being deleted so we can exclude them from the guard
var deletedSessions = CopilotService.Organization.Sessions
.Where(m => m.GroupId == groupId)
.Select(m => m.SessionName)
.ToHashSet();
CopilotService.DeleteGroup(groupId);
// Only delete worktree if no remaining sessions or groups still reference it
if (!IsWorktreeInUse(worktreeId, excludeSessions: deletedSessions))
{
await DeleteWorktreeAsync(worktreeId);
}
}

private async Task DeleteWorktreeAsync(string worktreeId)
{
try
{
if (CopilotService.IsRemoteMode)
await CopilotService.RemoveWorktreeViaBridgeAsync(worktreeId);
else
await RepoManager.RemoveWorktreeAsync(worktreeId);
}
catch (Exception ex)
{
Console.WriteLine($"[SessionSidebar] Failed to delete worktree {worktreeId}: {ex.Message}");
}
}

/// <summary>Check if any remaining sessions or groups reference the worktree.</summary>
private bool IsWorktreeInUse(string worktreeId, string? excludeSession = null, HashSet<string>? excludeSessions = null)
{
var org = CopilotService.Organization;
// Check organization metadata — exclude sessions we just closed
if (org.Sessions.Any(s => s.WorktreeId == worktreeId
&& s.SessionName != excludeSession
&& (excludeSessions == null || !excludeSessions.Contains(s.SessionName))))
return true;
if (org.Groups.Any(g => g.WorktreeId == worktreeId))
return true;
// Also check live non-hidden sessions whose working directory matches the worktree path
var wt = RepoManager.Worktrees.FirstOrDefault(w => w.Id == worktreeId);
if (wt != null)
{
foreach (var session in CopilotService.GetAllSessions())
{
if (session.Name == excludeSession) continue;
if (excludeSessions != null && excludeSessions.Contains(session.Name)) continue;
if (!string.IsNullOrEmpty(session.WorkingDirectory)
&& session.WorkingDirectory.StartsWith(wt.Path, StringComparison.OrdinalIgnoreCase))
return true;
}
}
return false;
}

private void MoveSessionToGroup(string sessionName, ChangeEventArgs e)
{
if (e.Value is string groupId && !string.IsNullOrEmpty(groupId))
Expand Down
43 changes: 43 additions & 0 deletions PolyPilot/Models/BridgeMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,17 @@ public static class BridgeMessageTypes
public const string AddRepo = "add_repo";
public const string RemoveRepo = "remove_repo";
public const string ListRepos = "list_repos";
public const string CreateWorktree = "create_worktree";
public const string RemoveWorktree = "remove_worktree";

// Server → Client (repo responses)
public const string ReposList = "repos_list";
public const string RepoAdded = "repo_added";
public const string RepoProgress = "repo_progress";
public const string RepoError = "repo_error";
public const string WorktreeCreated = "worktree_created";
public const string WorktreeRemoved = "worktree_removed";
public const string WorktreeError = "worktree_error";

// Server → Client (response)
public const string DirectoriesList = "directories_list";
Expand Down Expand Up @@ -439,6 +444,7 @@ public class ReposListPayload
{
public string? RequestId { get; set; }
public List<RepoSummary> Repos { get; set; } = new();
public List<WorktreeSummary> Worktrees { get; set; } = new();
}

public class RepoSummary
Expand All @@ -448,6 +454,17 @@ public class RepoSummary
public string Url { get; set; } = "";
}

public class WorktreeSummary
{
public string Id { get; set; } = "";
public string RepoId { get; set; } = "";
public string Branch { get; set; } = "";
public string Path { get; set; } = "";
public int? PrNumber { get; set; }
/// <summary>Git remote name (e.g., "origin", "upstream") if this worktree was created from a PR and the remote exists locally.</summary>
public string? Remote { get; set; }
}

public class RepoAddedPayload
{
public string RequestId { get; set; } = "";
Expand All @@ -468,6 +485,32 @@ public class RepoErrorPayload
public string Error { get; set; } = "";
}

public class CreateWorktreePayload
{
public string RequestId { get; set; } = Guid.NewGuid().ToString();
public string RepoId { get; set; } = "";
public string? BranchName { get; set; }
public int? PrNumber { get; set; }
}

public class RemoveWorktreePayload
{
public string RequestId { get; set; } = "";
public string WorktreeId { get; set; } = "";
}

public class WorktreeCreatedPayload
{
public string RequestId { get; set; } = "";
public string WorktreeId { get; set; } = "";
public string RepoId { get; set; } = "";
public string Branch { get; set; } = "";
public string Path { get; set; } = "";
public int? PrNumber { get; set; }
/// <summary>Git remote name (e.g., "origin", "upstream") if this worktree was created from a PR and the remote exists locally.</summary>
public string? Remote { get; set; }
}

public class FetchImagePayload
{
public string Path { get; set; } = "";
Expand Down
2 changes: 2 additions & 0 deletions PolyPilot/Models/RepositoryInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public class WorktreeInfo
public string? SessionName { get; set; }
/// <summary>GitHub PR number if this worktree was created from a PR.</summary>
public int? PrNumber { get; set; }
/// <summary>Git remote name (e.g., "origin", "upstream") if this worktree was created from a PR and the remote exists locally.</summary>
public string? Remote { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

Expand Down
50 changes: 50 additions & 0 deletions PolyPilot/Services/CopilotService.Bridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,39 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati
SyncRemoteSessions();
InvokeOnUI(() => OnStateChanged?.Invoke());
};
_bridgeClient.OnReposListReceived += payload =>
{
// Must run on UI thread — RepoManager lists are iterated by Blazor components
InvokeOnUI(() =>
{
// Reconcile local RepoManager with server state — add new, remove stale
var serverRepoIds = new HashSet<string>(payload.Repos.Select(r => r.Id));
var serverWorktreeIds = new HashSet<string>(payload.Worktrees.Select(w => w.Id));

// Remove worktrees/repos that no longer exist on the server
foreach (var wt in _repoManager.Worktrees.ToList())
{
if (!serverWorktreeIds.Contains(wt.Id))
_repoManager.RemoveRemoteWorktree(wt.Id);
}
foreach (var r in _repoManager.Repositories.ToList())
{
if (!serverRepoIds.Contains(r.Id))
_repoManager.RemoveRemoteRepo(r.Id);
}

// Add new entries from server
foreach (var r in payload.Repos)
{
if (!_repoManager.Repositories.Any(existing => existing.Id == r.Id))
_repoManager.AddRemoteRepo(new RepositoryInfo { Id = r.Id, Name = r.Name, Url = r.Url });
}
foreach (var w in payload.Worktrees)
{
_repoManager.AddRemoteWorktree(new WorktreeInfo { Id = w.Id, RepoId = w.RepoId, Branch = w.Branch, Path = w.Path, PrNumber = w.PrNumber, Remote = w.Remote });
}
});
};
_bridgeClient.OnContentReceived += (s, c) =>
{
// Track that this session is actively streaming
Expand Down Expand Up @@ -221,6 +254,9 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati
NeedsConfiguration = false;
Debug($"Connected to remote server via WebSocket bridge ({_bridgeClient.Sessions.Count} sessions, {_bridgeClient.SessionHistories.Count} histories)");
OnStateChanged?.Invoke();

// Request repos/worktrees so the worktree picker works on mobile
_ = Task.Run(async () => { try { await _bridgeClient.RequestReposAsync(ct); } catch { } });
}

/// <summary>
Expand Down Expand Up @@ -387,4 +423,18 @@ public bool RepoExistsById(string repoId)
{
return _repoManager.Repositories.Any(r => r.Id == repoId);
}

public async Task<WorktreeCreatedPayload> CreateWorktreeViaBridgeAsync(string repoId, string? branchName, int? prNumber, CancellationToken ct = default)
{
if (!IsRemoteMode)
throw new InvalidOperationException("CreateWorktreeViaBridgeAsync is only for remote mode");
return await _bridgeClient.CreateWorktreeAsync(repoId, branchName, prNumber, ct);
}

public async Task RemoveWorktreeViaBridgeAsync(string worktreeId, CancellationToken ct = default)
{
if (!IsRemoteMode)
throw new InvalidOperationException("RemoveWorktreeViaBridgeAsync is only for remote mode");
await _bridgeClient.RemoveWorktreeAsync(worktreeId, ct);
}
}
4 changes: 4 additions & 0 deletions PolyPilot/Services/IWsBridgeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public interface IWsBridgeClient
Task RemoveRepoAsync(string repoId, bool deleteFromDisk, string? groupId = null, CancellationToken ct = default);
Task RequestReposAsync(CancellationToken ct = default);

// Worktree operations
Task<WorktreeCreatedPayload> CreateWorktreeAsync(string repoId, string? branchName, int? prNumber, CancellationToken ct = default);
Task RemoveWorktreeAsync(string worktreeId, CancellationToken ct = default);

// Image fetch
Task<FetchImageResponsePayload> FetchImageAsync(string path, CancellationToken ct = default);
}
Loading