Skip to content

Commit 4844743

Browse files
sallyomclaude
andcommitted
feat: simplified git multi-branch UX with filesystem polling
Implements a clean, observation-based approach to multi-branch git support that displays current filesystem state without complex programmatic operations. Architecture Changes: 1. CRD (agenticsessions-crd.yaml) for currently active branch and branches array 2. Runner (main.py): - Enhanced clone_repo_at_runtime() with intelligent branching: * No branch -> creates ambient-session-{uuid} * Branch does not exist -> creates from default branch * Same repo, new branch -> fetch and checkout - New /repos/status endpoint polls git for: 3. Operator (sessions.go): - Added pollRunnerReposStatus() function - Integrated polling into reconciliation loop - Updates status.reconciledRepos with actual filesystem state Frontend UX: 4. Context Modal (repositories-accordion.tsx): - Blue badge showing currentActiveBranch (what is checked out) - Expandable branch list with chevron (read-only) - Active indicator for current branch - Graceful fallback to spec.branch 5. File Explorer (file-tree.tsx, page.tsx): - Branch badges on repo folders in /workspace/repos - Pulls from status.reconciledRepos.currentActiveBranch - Consistent blue badge styling Co-Authored-By: Claude Sonnet 4.5 <[email protected]> Signed-off-by: sallyom <[email protected]>
1 parent 3521644 commit 4844743

File tree

7 files changed

+432
-89
lines changed

7 files changed

+432
-89
lines changed

components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
"use client";
22

33
import { useState } from "react";
4-
import { GitBranch, X, Link, Loader2, CloudUpload } from "lucide-react";
4+
import { GitBranch, X, Link, Loader2, CloudUpload, ChevronDown, ChevronRight } from "lucide-react";
55
import { AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
66
import { Badge } from "@/components/ui/badge";
77
import { Button } from "@/components/ui/button";
88

99
type Repository = {
1010
url: string;
11-
branch?: string;
11+
name?: string;
12+
branch?: string; // DEPRECATED: Use currentActiveBranch instead
13+
branches?: string[]; // All local branches available
14+
currentActiveBranch?: string; // Currently checked out branch
15+
defaultBranch?: string; // Default branch of remote
1216
};
1317

1418
type UploadedFile = {
@@ -34,6 +38,7 @@ export function RepositoriesAccordion({
3438
}: RepositoriesAccordionProps) {
3539
const [removingRepo, setRemovingRepo] = useState<string | null>(null);
3640
const [removingFile, setRemovingFile] = useState<string | null>(null);
41+
const [expandedRepos, setExpandedRepos] = useState<Set<string>>(new Set());
3742

3843
const totalContextItems = repositories.length + uploadedFiles.length;
3944

@@ -95,36 +100,87 @@ export function RepositoriesAccordion({
95100
<div className="space-y-2">
96101
{/* Repositories */}
97102
{repositories.map((repo, idx) => {
98-
const repoName = repo.url.split('/').pop()?.replace('.git', '') || `repo-${idx}`;
103+
const repoName = repo.name || repo.url.split('/').pop()?.replace('.git', '') || `repo-${idx}`;
99104
const isRemoving = removingRepo === repoName;
105+
const isExpanded = expandedRepos.has(repoName);
106+
const currentBranch = repo.currentActiveBranch || repo.branch;
107+
const hasBranches = repo.branches && repo.branches.length > 0;
108+
109+
const toggleExpanded = () => {
110+
setExpandedRepos(prev => {
111+
const next = new Set(prev);
112+
if (next.has(repoName)) {
113+
next.delete(repoName);
114+
} else {
115+
next.add(repoName);
116+
}
117+
return next;
118+
});
119+
};
100120

101121
return (
102-
<div key={`repo-${idx}`} className="flex items-center gap-2 p-2 border rounded bg-muted/30 hover:bg-muted/50 transition-colors">
103-
<GitBranch className="h-4 w-4 text-muted-foreground flex-shrink-0" />
104-
<div className="flex-1 min-w-0">
105-
<div className="flex items-center gap-2">
106-
<div className="text-sm font-medium truncate">{repoName}</div>
107-
{repo.branch && (
108-
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 flex-shrink-0">
109-
{repo.branch}
110-
</Badge>
111-
)}
122+
<div key={`repo-${idx}`} className="border rounded bg-muted/30">
123+
<div className="flex items-center gap-2 p-2 hover:bg-muted/50 transition-colors">
124+
{hasBranches && (
125+
<button
126+
onClick={toggleExpanded}
127+
className="h-4 w-4 text-muted-foreground flex-shrink-0 hover:text-foreground"
128+
>
129+
{isExpanded ? (
130+
<ChevronDown className="h-4 w-4" />
131+
) : (
132+
<ChevronRight className="h-4 w-4" />
133+
)}
134+
</button>
135+
)}
136+
{!hasBranches && <div className="h-4 w-4 flex-shrink-0" />}
137+
<GitBranch className="h-4 w-4 text-muted-foreground flex-shrink-0" />
138+
<div className="flex-1 min-w-0">
139+
<div className="flex items-center gap-2">
140+
<div className="text-sm font-medium truncate">{repoName}</div>
141+
{currentBranch && (
142+
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 flex-shrink-0 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
143+
{currentBranch}
144+
</Badge>
145+
)}
146+
</div>
147+
<div className="text-xs text-muted-foreground truncate">{repo.url}</div>
112148
</div>
113-
<div className="text-xs text-muted-foreground truncate">{repo.url}</div>
149+
<Button
150+
variant="ghost"
151+
size="sm"
152+
className="h-7 w-7 p-0 flex-shrink-0"
153+
onClick={() => handleRemoveRepo(repoName)}
154+
disabled={isRemoving}
155+
>
156+
{isRemoving ? (
157+
<Loader2 className="h-3 w-3 animate-spin" />
158+
) : (
159+
<X className="h-3 w-3" />
160+
)}
161+
</Button>
114162
</div>
115-
<Button
116-
variant="ghost"
117-
size="sm"
118-
className="h-7 w-7 p-0 flex-shrink-0"
119-
onClick={() => handleRemoveRepo(repoName)}
120-
disabled={isRemoving}
121-
>
122-
{isRemoving ? (
123-
<Loader2 className="h-3 w-3 animate-spin" />
124-
) : (
125-
<X className="h-3 w-3" />
126-
)}
127-
</Button>
163+
164+
{/* Expandable branches list */}
165+
{isExpanded && hasBranches && (
166+
<div className="px-2 pb-2 pl-10 space-y-1">
167+
<div className="text-xs text-muted-foreground mb-1">Available branches:</div>
168+
{repo.branches!.map((branch, branchIdx) => (
169+
<div
170+
key={branchIdx}
171+
className="text-xs py-1 px-2 rounded bg-muted/50 flex items-center gap-2"
172+
>
173+
<GitBranch className="h-3 w-3 text-muted-foreground" />
174+
<span className="font-mono">{branch}</span>
175+
{branch === currentBranch && (
176+
<Badge variant="secondary" className="text-xs px-1 py-0 h-4 ml-auto">
177+
active
178+
</Badge>
179+
)}
180+
</div>
181+
))}
182+
</div>
183+
)}
128184
</div>
129185
);
130186
})}

components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function AddContextModal({
8787
onChange={(e) => setContextBranch(e.target.value)}
8888
/>
8989
<p className="text-xs text-muted-foreground">
90-
Leave empty to use the default branch
90+
If left empty, a unique feature branch will be created for this session
9191
</p>
9292
</div>
9393

components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,7 +1533,7 @@ export default function ProjectSessionDetailPage({
15331533
/>
15341534

15351535
<RepositoriesAccordion
1536-
repositories={session?.spec?.repos || []}
1536+
repositories={session?.status?.reconciledRepos || session?.spec?.repos || []}
15371537
uploadedFiles={fileUploadsList.map((f) => ({
15381538
name: f.name,
15391539
path: f.path,
@@ -1769,14 +1769,37 @@ export default function ProjectSessionDetailPage({
17691769
) : (
17701770
<FileTree
17711771
nodes={directoryFiles.map(
1772-
(item): FileTreeNode => ({
1773-
name: item.name,
1774-
path: item.path,
1775-
type: item.isDir ? "folder" : "file",
1776-
sizeKb: item.size
1777-
? item.size / 1024
1778-
: undefined,
1779-
}),
1772+
(item): FileTreeNode => {
1773+
const node: FileTreeNode = {
1774+
name: item.name,
1775+
path: item.path,
1776+
type: item.isDir ? "folder" : "file",
1777+
sizeKb: item.size
1778+
? item.size / 1024
1779+
: undefined,
1780+
};
1781+
1782+
// Add branch badge for repo folders in /workspace/repos
1783+
if (
1784+
item.isDir &&
1785+
(selectedDirectory.path === "/workspace/repos" ||
1786+
selectedDirectory.path === "repos")
1787+
) {
1788+
const repo = session?.status?.reconciledRepos?.find(
1789+
(r: { name?: string; url?: string }) => {
1790+
const repoName = r.name || r.url?.split("/").pop()?.replace(".git", "");
1791+
return repoName === item.name;
1792+
}
1793+
);
1794+
if (repo?.currentActiveBranch) {
1795+
node.branch = repo.currentActiveBranch;
1796+
} else if (repo?.branch) {
1797+
node.branch = repo.branch;
1798+
}
1799+
}
1800+
1801+
return node;
1802+
},
17801803
)}
17811804
onSelect={fileOps.handleFileOrFolderSelect}
17821805
/>

components/frontend/src/components/file-tree.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from "react";
44
import { Folder, FolderOpen, FileText } from "lucide-react";
5+
import { Badge } from "@/components/ui/badge";
56

67
export type FileTreeNode = {
78
name: string;
@@ -11,6 +12,7 @@ export type FileTreeNode = {
1112
expanded?: boolean;
1213
sizeKb?: number;
1314
data?: unknown;
15+
branch?: string; // Git branch for repository folders
1416
};
1517

1618
export type FileTreeProps = {
@@ -86,6 +88,12 @@ function FileTreeItem({ node, selectedPath, onSelect, onToggle, depth = 0 }: Ite
8688

8789
<span className={`flex-1 ${isSelected ? "font-medium" : ""}`}>{node.name}</span>
8890

91+
{node.branch && (
92+
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
93+
{node.branch}
94+
</Badge>
95+
)}
96+
8997
{typeof node.sizeKb === "number" && (
9098
<span className="text-xs text-muted-foreground">{node.sizeKb.toFixed(1)}K</span>
9199
)}

components/manifests/base/crds/agenticsessions-crd.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ spec:
129129
type: string
130130
branch:
131131
type: string
132+
description: "DEPRECATED: Use currentActiveBranch instead"
133+
branches:
134+
type: array
135+
description: "All local branches available in this repository"
136+
items:
137+
type: string
138+
currentActiveBranch:
139+
type: string
140+
description: "Currently checked out branch (polled from filesystem)"
141+
defaultBranch:
142+
type: string
143+
description: "Default branch of the remote repository (e.g., main, master)"
132144
name:
133145
type: string
134146
status:

components/operator/internal/handlers/sessions.go

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,15 +1559,46 @@ func reconcileSpecReposWithPatch(sessionNamespace, sessionName string, spec map[
15591559
}
15601560
}
15611561

1562-
// Update status to reflect the reconciled state (via statusPatch)
1562+
// Poll runner for actual repository status (branches, currentActiveBranch, etc.)
1563+
actualRepos := pollRunnerReposStatus(runnerBaseURL)
1564+
1565+
// Update status to reflect the reconciled state with actual filesystem data
15631566
reconciled := make([]interface{}, 0, len(specRepos))
15641567
for _, repo := range specRepos {
1565-
reconciled = append(reconciled, map[string]interface{}{
1566-
"url": repo["url"],
1567-
"branch": repo["branch"],
1568-
"status": "Ready",
1568+
repoName := deriveRepoNameFromURL(repo["url"])
1569+
1570+
// Find matching actual repo status from runner
1571+
var actualRepo map[string]interface{}
1572+
for _, actual := range actualRepos {
1573+
if actualName, ok := actual["name"].(string); ok && actualName == repoName {
1574+
actualRepo = actual
1575+
break
1576+
}
1577+
}
1578+
1579+
// Build reconciled entry with actual data if available
1580+
reconciledEntry := map[string]interface{}{
1581+
"url": repo["url"],
1582+
"name": repoName,
1583+
"branch": repo["branch"], // Intended branch from spec (deprecated)
1584+
"status": "Ready",
15691585
"clonedAt": time.Now().UTC().Format(time.RFC3339),
1570-
})
1586+
}
1587+
1588+
// Add actual branch information from runner
1589+
if actualRepo != nil {
1590+
if branches, ok := actualRepo["branches"].([]interface{}); ok {
1591+
reconciledEntry["branches"] = branches
1592+
}
1593+
if currentBranch, ok := actualRepo["currentActiveBranch"].(string); ok {
1594+
reconciledEntry["currentActiveBranch"] = currentBranch
1595+
}
1596+
if defaultBranch, ok := actualRepo["defaultBranch"].(string); ok {
1597+
reconciledEntry["defaultBranch"] = defaultBranch
1598+
}
1599+
}
1600+
1601+
reconciled = append(reconciled, reconciledEntry)
15711602
}
15721603
statusPatch.SetField("reconciledRepos", reconciled)
15731604
statusPatch.AddCondition(conditionUpdate{
@@ -2251,6 +2282,40 @@ func deriveRepoNameFromURL(repoURL string) string {
22512282
return "repo"
22522283
}
22532284

2285+
// pollRunnerReposStatus queries the runner's /repos/status endpoint to get actual filesystem state.
2286+
// Returns array of repo status objects with branches, currentActiveBranch, defaultBranch.
2287+
func pollRunnerReposStatus(runnerBaseURL string) []map[string]interface{} {
2288+
req, err := http.NewRequest("GET", runnerBaseURL+"/repos/status", nil)
2289+
if err != nil {
2290+
log.Printf("[PollRepos] Failed to create request: %v", err)
2291+
return []map[string]interface{}{}
2292+
}
2293+
2294+
client := &http.Client{Timeout: 5 * time.Second}
2295+
resp, err := client.Do(req)
2296+
if err != nil {
2297+
log.Printf("[PollRepos] Failed to poll runner: %v", err)
2298+
return []map[string]interface{}{}
2299+
}
2300+
defer resp.Body.Close()
2301+
2302+
if resp.StatusCode != http.StatusOK {
2303+
log.Printf("[PollRepos] Runner returned status %d", resp.StatusCode)
2304+
return []map[string]interface{}{}
2305+
}
2306+
2307+
var result struct {
2308+
Repos []map[string]interface{} `json:"repos"`
2309+
}
2310+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
2311+
log.Printf("[PollRepos] Failed to decode response: %v", err)
2312+
return []map[string]interface{}{}
2313+
}
2314+
2315+
log.Printf("[PollRepos] Successfully polled %d repos from runner", len(result.Repos))
2316+
return result.Repos
2317+
}
2318+
22542319
// regenerateRunnerToken provisions a fresh ServiceAccount, Role, RoleBinding, and token Secret for a session.
22552320
// This is called when restarting sessions to ensure fresh tokens.
22562321
func regenerateRunnerToken(sessionNamespace, sessionName string, session *unstructured.Unstructured) error {

0 commit comments

Comments
 (0)