Skip to content

Commit 4bd6860

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. Context Modal (repositories-accordion.tsx): - Blue badge showing currentActiveBranch (what is checked out) File Explorer (file-tree.tsx, page.tsx): - Branch badges on repo folders in /workspace/repos - Consistent blue badge styling Co-Authored-By: Claude Sonnet 4.5 <[email protected]> Signed-off-by: sallyom <[email protected]>
1 parent 3521644 commit 4bd6860

File tree

15 files changed

+782
-158
lines changed

15 files changed

+782
-158
lines changed

components/backend/handlers/sessions.go

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ func CreateSession(c *gin.Context) {
555555

556556
// Generate unique name
557557
timestamp := time.Now().Unix()
558-
name := fmt.Sprintf("agentic-session-%d", timestamp)
558+
name := fmt.Sprintf("ambient-%d", timestamp%10000)
559559

560560
// Create the custom resource
561561
// Metadata
@@ -1459,22 +1459,57 @@ func RemoveRepo(c *gin.Context) {
14591459
repos, _ := spec["repos"].([]interface{})
14601460

14611461
filteredRepos := []interface{}{}
1462-
found := false
1462+
foundInSpec := false
14631463
for _, r := range repos {
14641464
rm, _ := r.(map[string]interface{})
14651465
url, _ := rm["url"].(string)
14661466
if DeriveRepoFolderFromURL(url) != repoName {
14671467
filteredRepos = append(filteredRepos, r)
14681468
} else {
1469-
found = true
1469+
foundInSpec = true
14701470
}
14711471
}
14721472

1473-
if !found {
1473+
// Also check status.reconciledRepos for repos added directly to runner
1474+
status, _ := item.Object["status"].(map[string]interface{})
1475+
reconciledRepos, _ := status["reconciledRepos"].([]interface{})
1476+
foundInReconciled := false
1477+
for _, r := range reconciledRepos {
1478+
rm, _ := r.(map[string]interface{})
1479+
name, _ := rm["name"].(string)
1480+
if name == repoName {
1481+
foundInReconciled = true
1482+
break
1483+
}
1484+
// Also try matching by URL
1485+
url, _ := rm["url"].(string)
1486+
if DeriveRepoFolderFromURL(url) == repoName {
1487+
foundInReconciled = true
1488+
break
1489+
}
1490+
}
1491+
1492+
if !foundInSpec && !foundInReconciled {
14741493
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found in session"})
14751494
return
14761495
}
14771496

1497+
// Always call runner to remove from filesystem (if session is running)
1498+
phase, _ := status["phase"].(string)
1499+
if phase == "Running" {
1500+
runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8001/repos/remove", sessionName, project)
1501+
runnerReq := map[string]string{"name": repoName}
1502+
reqBody, _ := json.Marshal(runnerReq)
1503+
resp, err := http.Post(runnerURL, "application/json", bytes.NewReader(reqBody))
1504+
if err != nil {
1505+
log.Printf("Warning: failed to call runner /repos/remove: %v", err)
1506+
// Continue anyway - at least update the CR
1507+
} else {
1508+
resp.Body.Close()
1509+
log.Printf("Called runner to remove repo %s from filesystem", repoName)
1510+
}
1511+
}
1512+
14781513
spec["repos"] = filteredRepos
14791514

14801515
// Persist change
@@ -3069,6 +3104,60 @@ func DiffSessionRepo(c *gin.Context) {
30693104
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes)
30703105
}
30713106

3107+
// GetReposStatus returns current status of all repositories (branches, current branch, etc.)
3108+
// GET /api/projects/:projectName/agentic-sessions/:sessionName/repos/status
3109+
func GetReposStatus(c *gin.Context) {
3110+
project := c.Param("projectName")
3111+
session := c.Param("sessionName")
3112+
3113+
k8sClt, _ := GetK8sClientsForRequest(c)
3114+
if k8sClt == nil {
3115+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
3116+
c.Abort()
3117+
return
3118+
}
3119+
3120+
// Call runner's /repos/status endpoint directly
3121+
// Port 8001 matches AG-UI Service defined in operator (sessions.go:1384)
3122+
// If changing this port, also update: operator containerPort, Service port, and AGUI_PORT env
3123+
runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8001/repos/status", session, project)
3124+
3125+
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, runnerURL, nil)
3126+
if err != nil {
3127+
log.Printf("GetReposStatus: failed to create HTTP request: %v", err)
3128+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
3129+
return
3130+
}
3131+
if v := c.GetHeader("Authorization"); v != "" {
3132+
req.Header.Set("Authorization", v)
3133+
}
3134+
3135+
client := &http.Client{Timeout: 5 * time.Second}
3136+
resp, err := client.Do(req)
3137+
if err != nil {
3138+
log.Printf("GetReposStatus: runner not reachable: %v", err)
3139+
// Return empty repos list instead of error for better UX
3140+
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
3141+
return
3142+
}
3143+
defer resp.Body.Close()
3144+
3145+
bodyBytes, err := io.ReadAll(resp.Body)
3146+
if err != nil {
3147+
log.Printf("GetReposStatus: failed to read response body: %v", err)
3148+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response from runner"})
3149+
return
3150+
}
3151+
3152+
if resp.StatusCode != http.StatusOK {
3153+
log.Printf("GetReposStatus: runner returned status %d", resp.StatusCode)
3154+
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
3155+
return
3156+
}
3157+
3158+
c.Data(http.StatusOK, "application/json", bodyBytes)
3159+
}
3160+
30723161
// GetGitStatus returns git status for a directory in the workspace
30733162
// GET /api/projects/:projectName/agentic-sessions/:sessionName/git/status?path=artifacts
30743163
func GetGitStatus(c *gin.Context) {

components/backend/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ func registerRoutes(r *gin.Engine) {
6565
projectGroup.POST("/agentic-sessions/:sessionName/workflow", handlers.SelectWorkflow)
6666
projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata)
6767
projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo)
68+
// NOTE: /repos/status must come BEFORE /repos/:repoName to avoid wildcard matching
69+
projectGroup.GET("/agentic-sessions/:sessionName/repos/status", handlers.GetReposStatus)
6870
projectGroup.DELETE("/agentic-sessions/:sessionName/repos/:repoName", handlers.RemoveRepo)
6971
projectGroup.PUT("/agentic-sessions/:sessionName/displayname", handlers.UpdateSessionDisplayName)
7072

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { BACKEND_URL } from '@/lib/config';
2+
import { buildForwardHeadersAsync } from '@/lib/auth';
3+
4+
export async function GET(
5+
request: Request,
6+
{ params }: { params: Promise<{ name: string; sessionName: string }> },
7+
) {
8+
const { name, sessionName } = await params;
9+
const headers = await buildForwardHeadersAsync(request);
10+
11+
const resp = await fetch(
12+
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/repos/status`,
13+
{
14+
method: 'GET',
15+
headers,
16+
}
17+
);
18+
19+
const data = await resp.text();
20+
return new Response(data, {
21+
status: resp.status,
22+
headers: { 'Content-Type': 'application/json' }
23+
});
24+
}

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: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type AddContextModalProps = {
1616
onAddRepository: (url: string, branch: string, autoPush?: boolean) => Promise<void>;
1717
onUploadFile?: () => void;
1818
isLoading?: boolean;
19+
sessionName?: string;
1920
};
2021

2122
export function AddContextModal({
@@ -24,6 +25,7 @@ export function AddContextModal({
2425
onAddRepository,
2526
onUploadFile,
2627
isLoading = false,
28+
sessionName,
2729
}: AddContextModalProps) {
2830
const [contextUrl, setContextUrl] = useState("");
2931
const [contextBranch, setContextBranch] = useState("main");
@@ -32,7 +34,8 @@ export function AddContextModal({
3234
const handleSubmit = async () => {
3335
if (!contextUrl.trim()) return;
3436

35-
await onAddRepository(contextUrl.trim(), contextBranch.trim() || 'main', autoPush);
37+
const defaultBranch = sessionName ? `sessions/${sessionName}` : 'main';
38+
await onAddRepository(contextUrl.trim(), contextBranch.trim() || defaultBranch, autoPush);
3639

3740
// Reset form
3841
setContextUrl("");
@@ -82,12 +85,12 @@ export function AddContextModal({
8285
<Label htmlFor="context-branch">Branch (optional)</Label>
8386
<Input
8487
id="context-branch"
85-
placeholder="main"
88+
placeholder={sessionName ? `sessions/${sessionName}` : "main"}
8689
value={contextBranch}
8790
onChange={(e) => setContextBranch(e.target.value)}
8891
/>
8992
<p className="text-xs text-muted-foreground">
90-
Leave empty to use the default branch
93+
If left empty, a unique feature branch will be created for this session
9194
</p>
9295
</div>
9396

components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-git-operations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function useGitOperations({
3737
sessionName,
3838
path: directoryPath,
3939
remoteUrl: remoteUrl.trim(),
40-
branch: branch.trim() || "main",
40+
branch: branch.trim(),
4141
});
4242

4343
successToast("Remote configured successfully");

0 commit comments

Comments
 (0)