Skip to content

Commit 8eb479f

Browse files
sallyomclaude
andcommitted
feat: Improve git repository context UX (Issue #376)
Enhances the user experience for adding git repositories as context. Frontend UX Improvements: 1. Enhanced Add Context Modal - Repository options with base/feature branch configuration, protected branch detection, sync configuration 2. Improved Repository Dialog - Branch configuration with branch fetching, protected branch warnings, sync support 3. Enhanced Repository Display - Visual badges, color-coded branch pills, improved layout Co-Authored-By: Claude Sonnet 4.5 <[email protected]> Signed-off-by: sallyom <[email protected]>
1 parent f5fac44 commit 8eb479f

File tree

14 files changed

+1029
-125
lines changed

14 files changed

+1029
-125
lines changed

components/backend/handlers/helpers.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log"
77
"math"
8+
"strings"
89
"time"
910

1011
authv1 "k8s.io/api/authorization/v1"
@@ -74,3 +75,74 @@ func ValidateSecretAccess(ctx context.Context, k8sClient kubernetes.Interface, n
7475

7576
return nil
7677
}
78+
79+
// isProtectedBranch checks if a branch name is commonly protected
80+
func isProtectedBranch(branch string) bool {
81+
if branch == "" {
82+
return false
83+
}
84+
protectedNames := []string{
85+
"main", "master", "develop", "dev", "development",
86+
"production", "prod", "staging", "stage", "qa", "test", "stable",
87+
}
88+
branchLower := strings.ToLower(strings.TrimSpace(branch))
89+
for _, protected := range protectedNames {
90+
if branchLower == protected {
91+
return true
92+
}
93+
}
94+
return false
95+
}
96+
97+
// sanitizeBranchName converts a display name to a valid git branch name
98+
func sanitizeBranchName(name string) string {
99+
// Replace spaces with hyphens
100+
name = strings.ReplaceAll(name, " ", "-")
101+
// Remove or replace invalid characters for git branch names
102+
// Valid: alphanumeric, dash, underscore, slash, dot (but not at start/end)
103+
var result strings.Builder
104+
for _, r := range name {
105+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
106+
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' {
107+
result.WriteRune(r)
108+
}
109+
}
110+
sanitized := result.String()
111+
// Trim leading/trailing dashes or slashes
112+
sanitized = strings.Trim(sanitized, "-/")
113+
return sanitized
114+
}
115+
116+
// generateWorkingBranch generates a working branch name based on the session and repo context
117+
// Returns the branch name to use for the session
118+
func generateWorkingBranch(sessionDisplayName, sessionID, requestedBranch string, allowProtectedWork bool) string {
119+
// If user explicitly requested a branch
120+
if requestedBranch != "" {
121+
// Check if it's protected and user hasn't allowed working on it
122+
if isProtectedBranch(requestedBranch) && !allowProtectedWork {
123+
// Create a temporary working branch to protect the base branch
124+
sessionIDShort := sessionID
125+
if len(sessionID) > 8 {
126+
sessionIDShort = sessionID[:8]
127+
}
128+
return fmt.Sprintf("work/%s/%s", requestedBranch, sessionIDShort)
129+
}
130+
// User requested non-protected branch or explicitly allowed protected work
131+
return requestedBranch
132+
}
133+
134+
// No branch requested - generate from session name
135+
if sessionDisplayName != "" {
136+
sanitized := sanitizeBranchName(sessionDisplayName)
137+
if sanitized != "" {
138+
return sanitized
139+
}
140+
}
141+
142+
// Fallback: use session ID
143+
sessionIDShort := sessionID
144+
if len(sessionID) > 8 {
145+
sessionIDShort = sessionID[:8]
146+
}
147+
return fmt.Sprintf("session-%s", sessionIDShort)
148+
}

components/backend/handlers/sessions.go

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -621,14 +621,39 @@ func CreateSession(c *gin.Context) {
621621
}
622622

623623
// Set multi-repo configuration on spec (simplified format)
624+
// Generate working branch names upfront based on session context
624625
{
625626
spec := session["spec"].(map[string]interface{})
626627
if len(req.Repos) > 0 {
627628
arr := make([]map[string]interface{}, 0, len(req.Repos))
628629
for _, r := range req.Repos {
629-
m := map[string]interface{}{"url": r.URL}
630-
if r.Branch != nil {
631-
m["branch"] = *r.Branch
630+
// Determine the working branch to use
631+
var requestedBranch string
632+
if r.WorkingBranch != nil {
633+
requestedBranch = strings.TrimSpace(*r.WorkingBranch)
634+
} else if r.Branch != nil {
635+
requestedBranch = strings.TrimSpace(*r.Branch)
636+
}
637+
638+
allowProtected := false
639+
if r.AllowProtectedWork != nil {
640+
allowProtected = *r.AllowProtectedWork
641+
}
642+
643+
// Generate the actual branch name that will be used
644+
workingBranch := generateWorkingBranch(
645+
req.DisplayName,
646+
name, // session name (unique ID)
647+
requestedBranch,
648+
allowProtected,
649+
)
650+
651+
// Wrap in 'input' object to match runner expectations
652+
m := map[string]interface{}{
653+
"input": map[string]interface{}{
654+
"url": r.URL,
655+
"branch": workingBranch,
656+
},
632657
}
633658
arr = append(arr, m)
634659
}
@@ -1406,19 +1431,17 @@ func AddRepo(c *gin.Context) {
14061431
}
14071432

14081433
var req struct {
1409-
URL string `json:"url" binding:"required"`
1410-
Branch string `json:"branch"`
1434+
URL string `json:"url" binding:"required"`
1435+
Branch string `json:"branch"`
1436+
WorkingBranch string `json:"workingBranch"`
1437+
AllowProtectedWork bool `json:"allowProtectedWork"`
14111438
}
14121439

14131440
if err := c.ShouldBindJSON(&req); err != nil {
14141441
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
14151442
return
14161443
}
14171444

1418-
if req.Branch == "" {
1419-
req.Branch = "main"
1420-
}
1421-
14221445
gvr := GetAgenticSessionV1Alpha1Resource()
14231446
item, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{})
14241447
if err != nil {
@@ -1447,9 +1470,32 @@ func AddRepo(c *gin.Context) {
14471470
repos = []interface{}{}
14481471
}
14491472

1473+
// Determine the requested branch
1474+
requestedBranch := req.WorkingBranch
1475+
if requestedBranch == "" {
1476+
requestedBranch = req.Branch
1477+
}
1478+
1479+
// Get session display name for branch generation
1480+
displayName := ""
1481+
if dn, ok := spec["displayName"].(string); ok {
1482+
displayName = dn
1483+
}
1484+
1485+
// Generate the actual working branch name
1486+
workingBranch := generateWorkingBranch(
1487+
displayName,
1488+
sessionName,
1489+
requestedBranch,
1490+
req.AllowProtectedWork,
1491+
)
1492+
1493+
// Wrap in 'input' object to match runner expectations
14501494
newRepo := map[string]interface{}{
1451-
"url": req.URL,
1452-
"branch": req.Branch,
1495+
"input": map[string]interface{}{
1496+
"url": req.URL,
1497+
"branch": workingBranch,
1498+
},
14531499
}
14541500
repos = append(repos, newRepo)
14551501
spec["repos"] = repos

components/backend/types/session.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ type AgenticSessionSpec struct {
2828

2929
// SimpleRepo represents a simplified repository configuration
3030
type SimpleRepo struct {
31-
URL string `json:"url"`
32-
Branch *string `json:"branch,omitempty"`
31+
URL string `json:"url"`
32+
Branch *string `json:"branch,omitempty"`
33+
WorkingBranch *string `json:"workingBranch,omitempty"` // User-requested working branch (input only)
34+
AllowProtectedWork *bool `json:"allowProtectedWork,omitempty"` // Allow work directly on protected branches (input only)
3335
}
3436

3537
type AgenticSessionStatus struct {

components/frontend/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@radix-ui/react-accordion": "^1.2.12",
1414
"@radix-ui/react-avatar": "^1.1.10",
1515
"@radix-ui/react-checkbox": "^1.3.3",
16+
"@radix-ui/react-collapsible": "^1.1.12",
1617
"@radix-ui/react-dropdown-menu": "^2.1.16",
1718
"@radix-ui/react-label": "^2.1.7",
1819
"@radix-ui/react-progress": "^1.1.7",

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

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
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, GitMerge, Shield } 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+
branch?: string; // The actual working branch (generated by backend)
12+
allowProtectedWork?: boolean;
13+
sync?: {
14+
url: string;
15+
branch?: string;
16+
};
1217
};
1318

1419
type UploadedFile = {
@@ -78,7 +83,7 @@ export function RepositoriesAccordion({
7883
<p className="text-sm text-muted-foreground">
7984
Add additional context to improve AI responses.
8085
</p>
81-
86+
8287
{/* Context Items List (Repos + Uploaded Files) */}
8388
{totalContextItems === 0 ? (
8489
<div className="text-center py-6">
@@ -98,26 +103,63 @@ export function RepositoriesAccordion({
98103
const repoName = repo.url.split('/').pop()?.replace('.git', '') || `repo-${idx}`;
99104
const isRemoving = removingRepo === repoName;
100105

106+
// Get the actual working branch from spec (generated by backend)
107+
const workingBranch = repo.branch || 'main';
108+
const hasSync = !!repo.sync?.url;
109+
const allowProtected = repo.allowProtectedWork;
110+
101111
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="text-sm font-medium truncate">{repoName}</div>
106-
<div className="text-xs text-muted-foreground truncate">{repo.url}</div>
112+
<div key={`repo-${idx}`} className="border rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
113+
<div className="flex items-start gap-2 p-3">
114+
<GitBranch className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
115+
<div className="flex-1 min-w-0">
116+
<div className="flex items-center gap-2 mb-1">
117+
<div className="text-sm font-medium truncate">{repoName}</div>
118+
{hasSync && (
119+
<Badge variant="outline" className="text-xs">
120+
<GitMerge className="h-3 w-3 mr-1" />
121+
Synced
122+
</Badge>
123+
)}
124+
{allowProtected && (
125+
<Badge variant="outline" className="text-xs text-orange-600 border-orange-300">
126+
<Shield className="h-3 w-3 mr-1" />
127+
Protected
128+
</Badge>
129+
)}
130+
</div>
131+
<div className="text-xs text-muted-foreground truncate mb-2">{repo.url}</div>
132+
133+
{/* Branch information */}
134+
<div className="flex flex-wrap gap-1 text-xs">
135+
<div className="inline-flex items-center gap-1 bg-blue-50 dark:bg-blue-950 px-2 py-0.5 rounded border border-blue-200 dark:border-blue-800">
136+
<span className="text-blue-700 dark:text-blue-300">Branch:</span>
137+
<span className="font-mono text-blue-900 dark:text-blue-100">{workingBranch}</span>
138+
</div>
139+
{hasSync && (
140+
<div className="inline-flex items-center gap-1 bg-green-50 dark:bg-green-950 px-2 py-0.5 rounded border border-green-200 dark:border-green-800">
141+
<GitMerge className="h-3 w-3 text-green-600 dark:text-green-400" />
142+
<span className="text-green-700 dark:text-green-300">
143+
{repo.sync?.url.split('/').pop()?.replace('.git', '')}
144+
</span>
145+
</div>
146+
)}
147+
</div>
148+
</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>
107162
</div>
108-
<Button
109-
variant="ghost"
110-
size="sm"
111-
className="h-7 w-7 p-0 flex-shrink-0"
112-
onClick={() => handleRemoveRepo(repoName)}
113-
disabled={isRemoving}
114-
>
115-
{isRemoving ? (
116-
<Loader2 className="h-3 w-3 animate-spin" />
117-
) : (
118-
<X className="h-3 w-3" />
119-
)}
120-
</Button>
121163
</div>
122164
);
123165
})}
@@ -166,4 +208,3 @@ export function RepositoriesAccordion({
166208
</AccordionItem>
167209
);
168210
}
169-

0 commit comments

Comments
 (0)