From 0a986a527437967c9e6972980fc3c81a5a0e8bb6 Mon Sep 17 00:00:00 2001 From: setkyar Date: Sun, 31 May 2026 23:24:53 +0700 Subject: [PATCH 1/2] feat(ui): darken chrome surfaces and light the content panel Introduce --chrome-bg/--input-bg tokens (derived per theme) so sidebars, top bar and the composer bar read darker than the body while the reading column and input box stay lighter. Lift the chat input, fix the resizer gap, and make the scratchpad placeholder legible. --- internal/ui/live_templates/styles/index.css | 2 +- internal/ui/live_templates/styles/session.css | 22 ++++++++++++------- internal/ui/live_templates/styles/theme.css | 9 ++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/internal/ui/live_templates/styles/index.css b/internal/ui/live_templates/styles/index.css index 6346dc1..b324203 100644 --- a/internal/ui/live_templates/styles/index.css +++ b/internal/ui/live_templates/styles/index.css @@ -34,7 +34,7 @@ a { color: inherit; } position: sticky; top: 0; z-index: 20; - background: color-mix(in srgb, var(--body-bg) 92%, transparent); + background: color-mix(in srgb, var(--chrome-bg) 92%, transparent); border-bottom: 1px solid var(--dim); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index f4d1a65..ab151a2 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -454,6 +454,10 @@ align-items: stretch; height: calc(100vh - 52px); margin-top: 52px; + /* Chrome shell behind the resizer gutters so no body-bg slivers show + beside the darker composer / sidebars. The content column re-lights + itself with --body-bg below. */ + background: var(--chrome-bg); } /* Sidebar — height comes from #app's fixed height */ @@ -461,7 +465,7 @@ width: var(--sidebar-width); min-width: var(--sidebar-width); max-width: var(--sidebar-width); - background: var(--body-bg); + background: var(--chrome-bg); flex-shrink: 0; display: flex; flex-direction: column; @@ -510,7 +514,7 @@ width: var(--right-sidebar-width); min-width: var(--right-sidebar-width); max-width: var(--right-sidebar-width); - background: var(--body-bg); + background: var(--chrome-bg); flex-shrink: 0; display: flex; flex-direction: column; @@ -547,7 +551,7 @@ .right-sidebar-header { padding: 8px 12px; flex-shrink: 0; - background: var(--body-bg); + background: var(--chrome-bg); border-bottom: 1px solid var(--dim); display: flex; align-items: center; @@ -615,7 +619,7 @@ } .scratchpad-textarea::placeholder { - color: var(--dim); + color: var(--muted); } .right-sidebar-footer { @@ -672,7 +676,7 @@ .sidebar-header { padding: 8px 12px; flex-shrink: 0; - background: var(--body-bg); + background: var(--chrome-bg); border-bottom: 1px solid var(--dim); } @@ -689,7 +693,7 @@ padding: 4px 8px; font-size: 11px; font-family: inherit; - background: var(--body-bg); + background: var(--input-bg); color: var(--text); border: 1px solid var(--dim); border-radius: 3px; @@ -851,6 +855,8 @@ display: flex; flex-direction: column; align-items: center; + /* The lit reading column on top of the chrome #app shell. */ + background: var(--body-bg); } #content > * { @@ -1038,7 +1044,7 @@ z-index: 101; height: 52px; padding: 0 18px; - background: color-mix(in srgb, var(--body-bg) 92%, transparent); + background: color-mix(in srgb, var(--chrome-bg) 92%, transparent); border-bottom: 1px solid var(--pi-menu-border); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); @@ -2199,7 +2205,7 @@ min-width: 0; box-sizing: border-box; overflow: visible; - background: var(--body-bg); + background: var(--input-bg); border: 1px solid var(--dim); border-radius: 6px; position: relative; diff --git a/internal/ui/live_templates/styles/theme.css b/internal/ui/live_templates/styles/theme.css index 8f9af0e..72a09ef 100644 --- a/internal/ui/live_templates/styles/theme.css +++ b/internal/ui/live_templates/styles/theme.css @@ -5,6 +5,15 @@ --palette-surface: var(--surface); --palette-surface-2: var(--surface-2); --palette-text-soft: var(--text-soft); + + /* + * Chrome surfaces (top bar + sidebars + composer bar) sit a touch darker + * than the body so the middle reading column reads as "lit up". The chat + * input box sits a touch lighter than the body. Both derive from --body-bg + * so every theme, including the runtime "custom" theme, gets them for free. + */ + --chrome-bg: color-mix(in srgb, var(--body-bg) 90%, #000); + --input-bg: color-mix(in srgb, var(--body-bg) 86%, #fff); } :root, From 2a58900e31e4626568a3d59a76fc6499a75e58a8 Mon Sep 17 00:00:00 2001 From: setkyar Date: Sun, 31 May 2026 23:25:08 +0700 Subject: [PATCH 2/2] feat(composer): add git branch indicator and PR actions to chat footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render a footer beneath the chat composer showing the current branch with inline rename (refused on the default branch, server-side guard). A smart split button picks the primary action from branch/PR/dirty state — Create PR, Commit & push, View PR or Merge PR — backed by new /api/git/info and /api/git/rename-branch endpoints and a fast server-side prerender to avoid a flash. --- internal/git/git.go | 228 ++++++++++++++++ internal/git/git_test.go | 137 ++++++++++ internal/server/git.go | 89 +++++++ internal/server/server.go | 2 + internal/ui/live_templates/chat_composer.html | 75 ++++++ internal/ui/live_templates/styles/session.css | 163 +++++++++++- internal/ui/session_page.go | 22 ++ web/src/session/chat/git-api.js | 9 + web/src/session/chat/git-footer.js | 251 ++++++++++++++++++ web/src/session/chat/git-footer.test.js | 157 +++++++++++ web/src/session/session.js | 9 + 11 files changed, 1139 insertions(+), 3 deletions(-) create mode 100644 internal/git/git.go create mode 100644 internal/git/git_test.go create mode 100644 internal/server/git.go create mode 100644 web/src/session/chat/git-api.js create mode 100644 web/src/session/chat/git-footer.js create mode 100644 web/src/session/chat/git-footer.test.js diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..f4ddf84 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,228 @@ +// Package git provides thin, read-mostly helpers around the local git CLI for +// the chat composer's branch indicator and PR button. All commands run with an +// explicit working directory and fixed argument lists (no shell), so session +// cwd values can never inject extra commands. +package git + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + "time" +) + +var ( + // ErrNotRepo is returned when dir is not inside a git work tree. + ErrNotRepo = errors.New("not a git repository") + // ErrInvalidBranchName is returned when a requested branch name is empty + // or contains characters git would reject. + ErrInvalidBranchName = errors.New("invalid branch name") + // ErrNoRemote is returned when no GitHub-style origin remote is configured. + ErrNoRemote = errors.New("no github remote configured") + // ErrDefaultBranch is returned when a rename targets the repository's + // default branch, which we refuse to rename. + ErrDefaultBranch = errors.New("refusing to rename the default branch") +) + +// branchNamePattern is intentionally stricter than git's own check-ref-format: +// it covers the names humans actually type and rejects anything exotic. +var branchNamePattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) + +// Info describes the git state surfaced in the composer footer. +type Info struct { + IsRepo bool `json:"isRepo"` + Branch string `json:"branch"` + // IsDefault marks the repository's default branch (no rename / no PR). + IsDefault bool `json:"isDefault"` + // HasChanges is true when the working tree is dirty or there are local + // commits not yet pushed to the upstream — i.e. there's something to push. + HasChanges bool `json:"hasChanges"` + // PRCreateURL is the GitHub "open a pull request" URL for this branch. + PRCreateURL string `json:"prCreateUrl"` + // PRURL is set when an OPEN pull request already exists for this branch, + // in which case the UI offers "View PR" instead of "Create PR". + PRURL string `json:"prUrl"` +} + +func run(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// CurrentBranch returns the checked-out branch name for dir. +func CurrentBranch(dir string) (string, error) { + if dir == "" { + return "", ErrNotRepo + } + branch, err := run(dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", ErrNotRepo + } + if branch == "" || branch == "HEAD" { + // Detached HEAD or empty repo: no editable branch. + return "", ErrNotRepo + } + return branch, nil +} + +// Describe gathers the branch and a best-effort GitHub PR URL for dir. A +// non-repo directory yields Info{IsRepo: false} with a nil error so callers can +// simply hide the footer. +func Describe(dir string) (Info, error) { + branch, err := CurrentBranch(dir) + if err != nil { + return Info{IsRepo: false}, nil + } + info := Info{IsRepo: true, Branch: branch, HasChanges: HasLocalChanges(dir)} + if def := DefaultBranch(dir); def != "" && def == branch { + info.IsDefault = true + } + if url, err := pullRequestURL(dir, branch); err == nil { + info.PRCreateURL = url + } + // Only feature branches can have a PR against the default branch. + if !info.IsDefault { + info.PRURL = existingOpenPRURL(dir) + } + return info, nil +} + +// HasLocalChanges reports whether there is something to commit or push: either +// a dirty working tree, or local commits ahead of the upstream branch. +func HasLocalChanges(dir string) bool { + if out, err := run(dir, "status", "--porcelain"); err == nil && out != "" { + return true + } + if out, err := run(dir, "rev-list", "--count", "@{upstream}..HEAD"); err == nil { + if out != "" && out != "0" { + return true + } + } + return false +} + +// existingOpenPRURL returns the URL of an OPEN pull request for the current +// branch, using the gh CLI when available. It is best-effort: a missing/ +// unauthenticated gh, no PR, or a closed/merged PR all yield "". +func existingOpenPRURL(dir string) string { + gh, err := exec.LookPath("gh") + if err != nil { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, gh, "pr", "view", "--json", "url,state") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + var pr struct { + URL string `json:"url"` + State string `json:"state"` + } + if err := json.Unmarshal(out, &pr); err != nil { + return "" + } + if strings.EqualFold(pr.State, "OPEN") { + return pr.URL + } + return "" +} + +// DefaultBranch reports the repository's default branch. It prefers the +// remote's published HEAD (origin/HEAD) and falls back to a local main/master +// when that isn't configured. Returns "" when it can't be determined. +func DefaultBranch(dir string) string { + if out, err := run(dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"); err == nil && out != "" { + return strings.TrimPrefix(out, "origin/") + } + for _, candidate := range []string{"main", "master"} { + if _, err := run(dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+candidate); err == nil { + return candidate + } + } + return "" +} + +// RenameBranch renames the currently checked-out branch to name via +// `git branch -m`, validating the name first. +func RenameBranch(dir, name string) (string, error) { + name = strings.TrimSpace(name) + if !ValidBranchName(name) { + return "", ErrInvalidBranchName + } + branch, err := CurrentBranch(dir) + if err != nil { + return "", err + } + if def := DefaultBranch(dir); def != "" && def == branch { + return "", ErrDefaultBranch + } + cmd := exec.Command("git", "branch", "-m", name) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("%s", msg) + } + return name, nil +} + +// ValidBranchName reports whether name is safe to pass to git branch -m. +func ValidBranchName(name string) bool { + if name == "" || len(name) > 255 { + return false + } + if strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") { + return false + } + if strings.Contains(name, "..") || strings.Contains(name, "//") { + return false + } + return branchNamePattern.MatchString(name) +} + +// pullRequestURL turns the origin remote + branch into a GitHub "open a pull +// request" URL. Supports both SSH (git@github.com:owner/repo.git) and HTTPS +// remotes. Returns ErrNoRemote for non-GitHub or missing remotes. +func pullRequestURL(dir, branch string) (string, error) { + remote, err := run(dir, "remote", "get-url", "origin") + if err != nil || remote == "" { + return "", ErrNoRemote + } + slug, ok := githubSlug(remote) + if !ok { + return "", ErrNoRemote + } + return fmt.Sprintf("https://github.com/%s/pull/new/%s", slug, branch), nil +} + +// githubSlug extracts "owner/repo" from a github remote URL, or returns false. +func githubSlug(remote string) (string, bool) { + remote = strings.TrimSpace(remote) + remote = strings.TrimSuffix(remote, ".git") + + switch { + case strings.HasPrefix(remote, "git@github.com:"): + return strings.TrimPrefix(remote, "git@github.com:"), true + case strings.HasPrefix(remote, "ssh://git@github.com/"): + return strings.TrimPrefix(remote, "ssh://git@github.com/"), true + case strings.HasPrefix(remote, "https://github.com/"): + return strings.TrimPrefix(remote, "https://github.com/"), true + case strings.HasPrefix(remote, "http://github.com/"): + return strings.TrimPrefix(remote, "http://github.com/"), true + } + return "", false +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..421cfd8 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,137 @@ +package git + +import ( + "os/exec" + "path/filepath" + "testing" +) + +func initTestRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + mustGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, out) + } + } + mustGit("init") + mustGit("config", "user.email", "test@example.com") + mustGit("config", "user.name", "Test") + mustGit("commit", "--allow-empty", "-m", "init") + mustGit("branch", "-M", "main") + return dir +} + +func TestDescribeDefaultBranch(t *testing.T) { + dir := initTestRepo(t) + + info, err := Describe(dir) + if err != nil { + t.Fatalf("Describe: %v", err) + } + if !info.IsRepo || info.Branch != "main" { + t.Fatalf("got %+v, want repo on main", info) + } + if !info.IsDefault { + t.Fatalf("main should be reported as the default branch") + } + + // The default branch must not be renamable, even via the API directly. + if _, err := RenameBranch(dir, "renamed-main"); err != ErrDefaultBranch { + t.Fatalf("renaming default branch: got %v, want ErrDefaultBranch", err) + } + if info, _ := Describe(dir); info.Branch != "main" { + t.Fatalf("default branch was renamed to %q despite guard", info.Branch) + } + + // Create and switch to a feature branch so the rename below is allowed. + cmd := exec.Command("git", "checkout", "-b", "feature/tmp") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("checkout feature branch: %v (%s)", err, out) + } + + if _, err := RenameBranch(dir, "feature/x"); err != nil { + t.Fatalf("RenameBranch: %v", err) + } + info, _ = Describe(dir) + if info.Branch != "feature/x" { + t.Fatalf("got branch %q, want feature/x", info.Branch) + } + if info.IsDefault { + t.Fatalf("feature/x should not be the default branch") + } +} + +func TestDescribeNonRepo(t *testing.T) { + info, err := Describe(filepath.Join(t.TempDir(), "nope")) + if err != nil { + t.Fatalf("Describe non-repo returned error: %v", err) + } + if info.IsRepo { + t.Fatalf("expected IsRepo false for non-repo dir") + } +} + +func TestValidBranchName(t *testing.T) { + valid := []string{ + "main", + "feature/pr-button", + "fix_123", + "release-2.1.0", + "a", + } + for _, name := range valid { + if !ValidBranchName(name) { + t.Errorf("expected %q to be valid", name) + } + } + + invalid := []string{ + "", + "-leading-dash", + "/leading-slash", + "trailing-slash/", + "has space", + "double..dot", + "double//slash", + "semicolon;rm", + "tilde~name", + "caret^name", + "colon:name", + "quote\"name", + } + for _, name := range invalid { + if ValidBranchName(name) { + t.Errorf("expected %q to be invalid", name) + } + } +} + +func TestGithubSlug(t *testing.T) { + cases := []struct { + remote string + want string + ok bool + }{ + {"git@github.com:owner/repo.git", "owner/repo", true}, + {"git@github.com:owner/repo", "owner/repo", true}, + {"https://github.com/owner/repo.git", "owner/repo", true}, + {"https://github.com/owner/repo", "owner/repo", true}, + {"ssh://git@github.com/owner/repo.git", "owner/repo", true}, + {"git@gitlab.com:owner/repo.git", "", false}, + {"https://example.com/owner/repo.git", "", false}, + {"", "", false}, + } + for _, c := range cases { + got, ok := githubSlug(c.remote) + if ok != c.ok || got != c.want { + t.Errorf("githubSlug(%q) = (%q, %v), want (%q, %v)", c.remote, got, ok, c.want, c.ok) + } + } +} diff --git a/internal/server/git.go b/internal/server/git.go new file mode 100644 index 0000000..bb8ff6d --- /dev/null +++ b/internal/server/git.go @@ -0,0 +1,89 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "pi-web/internal/git" + "pi-web/internal/sessions" +) + +// resolveSessionCwd resolves a session id to its working directory (the cwd +// recorded in the session header). +func (s *Server) resolveSessionCwd(id string) (sessions.ResolvedSession, string, error) { + var resolved sessions.ResolvedSession + var err error + if s.cache != nil { + resolved, err = s.cache.Resolve(s.sessionsDir, id) + } else { + resolved, err = sessions.ResolveByID(s.sessionsDir, id) + } + if err != nil { + return resolved, "", err + } + cwd, _ := resolved.Session.Header["cwd"].(string) + return resolved, cwd, nil +} + +func writeSessionLookupError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, sessions.ErrInvalidSessionID): + writeJSONError(w, http.StatusBadRequest, "invalid session id") + case errors.Is(err, sessions.ErrSessionNotFound): + writeJSONError(w, http.StatusNotFound, "not found") + default: + writeJSONError(w, http.StatusInternalServerError, err.Error()) + } +} + +// handleGitInfo returns the current branch and a GitHub PR URL for the +// session's working directory. Non-repo cwds return {isRepo:false}. +func (s *Server) handleGitInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + _, cwd, err := s.resolveSessionCwd(r.URL.Query().Get("id")) + if err != nil { + writeSessionLookupError(w, err) + return + } + info, _ := git.Describe(cwd) + writeJSON(w, 0, info) +} + +// handleGitRenameBranch renames the checked-out branch in the session's cwd. +func (s *Server) handleGitRenameBranch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + _, cwd, err := s.resolveSessionCwd(r.URL.Query().Get("id")) + if err != nil { + writeSessionLookupError(w, err) + return + } + branch, err := git.RenameBranch(cwd, body.Name) + if err != nil { + switch { + case errors.Is(err, git.ErrInvalidBranchName): + writeJSONError(w, http.StatusBadRequest, "invalid branch name") + case errors.Is(err, git.ErrDefaultBranch): + writeJSONError(w, http.StatusBadRequest, "refusing to rename the default branch") + case errors.Is(err, git.ErrNotRepo): + writeJSONError(w, http.StatusBadRequest, "not a git repository") + default: + writeJSONError(w, http.StatusBadRequest, err.Error()) + } + return + } + writeJSON(w, 0, map[string]any{"ok": true, "branch": branch}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 5dd1c3d..1938869 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -171,6 +171,8 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/clone-session", s.auth.Wrap(s.handleApiCloneSession)) mux.HandleFunc("/api/rename-session", s.auth.Wrap(s.handleRenameSession)) mux.HandleFunc("/api/recent-locations", s.auth.Wrap(s.handleRecentLocations)) + mux.HandleFunc("/api/git/info", s.auth.Wrap(s.handleGitInfo)) + mux.HandleFunc("/api/git/rename-branch", s.auth.Wrap(s.handleGitRenameBranch)) mux.HandleFunc("/custom-themes.css", s.auth.Wrap(s.handleCustomThemes)) mux.HandleFunc("/api/scratchpad", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { diff --git a/internal/ui/live_templates/chat_composer.html b/internal/ui/live_templates/chat_composer.html index c384178..8116066 100644 --- a/internal/ui/live_templates/chat_composer.html +++ b/internal/ui/live_templates/chat_composer.html @@ -229,4 +229,79 @@ +
+
+ + {{.GitBranch}} + + +
+ +
+ + + +
+
diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index ab151a2..0edb834 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -2193,10 +2193,167 @@ width: 100%; box-sizing: border-box; display: block; - padding: 10px 14px calc(12px + env(safe-area-inset-bottom)); - background: var(--body-bg); - border-top: 1px solid color-mix(in srgb, var(--dim) 55%, transparent); + padding: 18px 14px calc(14px + env(safe-area-inset-bottom)); + background: var(--chrome-bg); + } + + /* Git branch + Create PR row beneath the input box. */ + .pi-git-bar { + width: 100%; + max-width: 760px; + margin: 8px auto 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 2px; + font-size: 11px; + } + .pi-git-bar[hidden] { display: none; } + + .pi-git-branch { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--muted); + } + .pi-git-icon { + width: 13px; + height: 13px; + fill: currentColor; + flex-shrink: 0; + } + .pi-git-branch-name { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-soft); + font-family: inherit; + } + .pi-git-edit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + flex-shrink: 0; + background: transparent; + color: var(--muted); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, color 0.15s, background 0.15s, border-color 0.15s; + } + .pi-git-edit:hover { + opacity: 1; + color: var(--text); + background: var(--hover); + border-color: var(--borderMuted); + } + .pi-git-edit svg { width: 12px; height: 12px; fill: currentColor; } + + .pi-git-branch-input { + min-width: 0; + flex: 1 1 220px; + max-width: 320px; + padding: 2px 7px; + font-size: 11px; + font-family: inherit; + background: var(--input-bg); + color: var(--text); + border: 1px solid color-mix(in srgb, var(--accent, #8abeb7) 60%, var(--dim)); + border-radius: 4px; + outline: none; + } + .pi-git-branch-input[hidden] { display: none; } + + .pi-git-actions { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } + .pi-git-actions > [hidden] { display: none; } + .pi-git-pr { + position: relative; + flex-shrink: 0; + } + .pi-git-pr-button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 11px; + font-size: 11px; + font-family: inherit; + background: transparent; + color: var(--text-soft); + border: 1px solid var(--dim); + border-radius: 5px; + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; + } + .pi-git-pr-button:hover, + .pi-git-pr-button[aria-expanded="true"] { + color: var(--text); + background: var(--hover); + border-color: var(--borderMuted); + } + + .pi-git-pr-menu { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + min-width: 180px; + background: var(--container-bg, var(--surface)); + border: 1px solid var(--dim); + border-radius: 6px; + box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.28); + padding: 4px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 2px; + } + .pi-git-pr-menu[hidden] { display: none; } + .pi-git-pr-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 9px; + font-size: 11px; + font-family: inherit; + background: transparent; + color: var(--text-soft); + border: 0; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + } + .pi-git-pr-item:hover { + background: var(--hover); + color: var(--text); + } + /* The class above sets display:block, which would otherwise defeat the + [hidden] attribute — restore hiding for toggled menu items. */ + .pi-git-pr-item[hidden] { display: none; } + + /* Split button: primary action + caret share a pill, divided by a hairline. */ + .pi-git-primary { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .pi-git-caret { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + padding-left: 7px; + padding-right: 7px; } + .pi-git-caret[hidden] { display: none; } .pi-chat-shell { width: 100%; diff --git a/internal/ui/session_page.go b/internal/ui/session_page.go index b652047..9ccf6e9 100644 --- a/internal/ui/session_page.go +++ b/internal/ui/session_page.go @@ -8,6 +8,7 @@ import ( "html/template" "strings" + "pi-web/internal/git" "pi-web/internal/sessions" ) @@ -169,18 +170,39 @@ func chatComposerHtmlForSession(session sessions.Session) string { modelLabel = modelLabel + " @ " + session.ModelProvider } } + // Pre-render the branch + the correct action control from fast, local git + // calls so the footer doesn't pop in after the async /api/git/info fetch. + // PR detection (which can hit the network via gh) stays in that async call. + gitIsRepo, gitBranch, gitIsDefault, gitHasChanges := false, "", false, false + if cwd != "" { + if b, err := git.CurrentBranch(cwd); err == nil { + gitIsRepo, gitBranch = true, b + if def := git.DefaultBranch(cwd); def != "" && def == b { + gitIsDefault = true + } + gitHasChanges = git.HasLocalChanges(cwd) + } + } data := struct { SessionID string ChatAvailable bool ChatDisabledReason string Cwd string ModelLabel string + GitIsRepo bool + GitBranch string + GitIsDefault bool + GitHasChanges bool }{ SessionID: session.ID, ChatAvailable: chatAvailable, ChatDisabledReason: session.ChatDisabledReason, Cwd: cwd, ModelLabel: modelLabel, + GitIsRepo: gitIsRepo, + GitBranch: gitBranch, + GitIsDefault: gitIsDefault, + GitHasChanges: gitHasChanges, } if !data.ChatAvailable && data.ChatDisabledReason == "" { data.ChatDisabledReason = "This session can be viewed, but chat is disabled because its working directory no longer exists." diff --git a/web/src/session/chat/git-api.js b/web/src/session/chat/git-api.js new file mode 100644 index 0000000..d16d834 --- /dev/null +++ b/web/src/session/chat/git-api.js @@ -0,0 +1,9 @@ +import { getJSON, postJSON } from '../../shared/api.js'; + +export function getGitInfo(sessionId, { getImpl = getJSON } = {}) { + return getImpl(`/api/git/info?id=${encodeURIComponent(sessionId)}`); +} + +export function renameBranch(sessionId, name, { postImpl = postJSON } = {}) { + return postImpl(`/api/git/rename-branch?id=${encodeURIComponent(sessionId)}`, { name }); +} diff --git a/web/src/session/chat/git-footer.js b/web/src/session/chat/git-footer.js new file mode 100644 index 0000000..3ad2ad3 --- /dev/null +++ b/web/src/session/chat/git-footer.js @@ -0,0 +1,251 @@ +// Wires the branch indicator + the smart git action control beneath the chat +// composer. The bar stays hidden unless the session cwd is a git repo. +// +// The right-hand control is a split button: a primary action chosen for the +// current state, plus a caret (▾) revealing the remaining relevant actions. + +export const DRAFT_PR_PROMPT = + 'Commit any uncommitted changes on this branch with a clear message, push ' + + 'the branch to the remote, then open a draft pull request with ' + + '`gh pr create --draft`. Review the diff first (`git status`, `git diff`, ' + + '`git log`) and write a clear PR title, a description summarizing what ' + + 'changed and why, and a short test plan.'; + +export const COMMIT_PUSH_PROMPT = + 'Review the current changes (run `git status` and `git diff`), then stage ' + + 'and commit them with a clear message summarizing what changed and why, and ' + + 'push to the remote.'; + +export const MERGE_PR_PROMPT = + 'Merge the pull request for this branch once its checks are green: run ' + + '`gh pr merge` (use a squash merge unless the project prefers otherwise) and ' + + 'delete the branch after merging.'; + +export function setupGitFooter({ + documentImpl = document, + windowImpl = window, + sessionId = '', + gitApi +} = {}) { + const bar = documentImpl.getElementById('pi-git-bar'); + if (!bar || !gitApi) return; + + const nameEl = documentImpl.getElementById('pi-git-branch-name'); + const editBtn = documentImpl.getElementById('pi-git-branch-edit'); + const input = documentImpl.getElementById('pi-git-branch-input'); + const prWrap = documentImpl.getElementById('pi-git-pr'); + const primaryBtn = documentImpl.getElementById('pi-git-primary'); + const primaryLabel = documentImpl.getElementById('pi-git-primary-label'); + const caretBtn = documentImpl.getElementById('pi-git-caret'); + const prMenu = documentImpl.getElementById('pi-git-pr-menu'); + const items = { + view: documentImpl.getElementById('pi-git-pr-view'), + draft: documentImpl.getElementById('pi-git-pr-draft'), + manual: documentImpl.getElementById('pi-git-pr-manual'), + merge: documentImpl.getElementById('pi-git-pr-merge'), + commit: documentImpl.getElementById('pi-git-pr-commit') + }; + + let currentBranch = ''; + let prCreateUrl = ''; + let existingPrUrl = ''; + let primaryAction = () => {}; + + function show(el, visible) { + if (el) el.hidden = !visible; + } + + function insertPrompt(text) { + const textarea = documentImpl.getElementById('pi-chat-message'); + if (!textarea) return; + textarea.value = text; + const EventCtor = windowImpl.Event || (typeof Event !== 'undefined' ? Event : null); + if (EventCtor) textarea.dispatchEvent(new EventCtor('input', { bubbles: true })); + if (typeof textarea.focus === 'function') textarea.focus(); + } + function openUrl(url) { + if (url && typeof windowImpl.open === 'function') windowImpl.open(url, '_blank', 'noopener'); + } + + // Each action is { id, label, run }. The plan picks one primary plus a list + // of secondary actions shown under the caret. + const ACTIONS = { + draft: { label: 'Create PR', run: () => insertPrompt(DRAFT_PR_PROMPT) }, + manual: { label: 'Create PR manually ↗', run: () => openUrl(prCreateUrl) }, + view: { label: 'View PR ↗', run: () => openUrl(existingPrUrl) }, + merge: { label: 'Merge PR', run: () => insertPrompt(MERGE_PR_PROMPT) }, + commit: { label: 'Commit & push', run: () => insertPrompt(COMMIT_PUSH_PROMPT) } + }; + + // Decide the primary action + secondary list from the current git state. + // "Commit & push" only appears when there is actually something to push. + function planActions({ isDefault, hasPr, hasChanges }) { + if (isDefault) { + // The only thing to do on the default branch is push pending changes. + return { primary: hasChanges ? 'commit' : null, secondary: [] }; + } + if (!hasPr) { + // "Create PR" already commits + pushes, so the only alternative offered + // is doing it by hand on GitHub. + return { primary: 'draft', secondary: ['manual'] }; + } + // An open PR already exists for this feature branch. + if (hasChanges) return { primary: 'commit', secondary: ['view', 'merge'] }; + return { primary: 'view', secondary: ['merge'] }; + } + + function applyInfo(info) { + if (!info || !info.isRepo || !info.branch) { + bar.hidden = true; + return; + } + currentBranch = info.branch; + prCreateUrl = info.prCreateUrl || ''; + existingPrUrl = info.prUrl || ''; + if (nameEl) nameEl.textContent = info.branch; + if (items.manual) items.manual.title = prCreateUrl ? prCreateUrl : 'No GitHub remote configured'; + + const isDefault = !!info.isDefault; + const hasPr = !isDefault && !!existingPrUrl; + const hasChanges = !!info.hasChanges; + + show(editBtn, !isDefault); + + const plan = planActions({ isDefault, hasPr, hasChanges }); + const primary = plan.primary ? ACTIONS[plan.primary] : null; + if (primary) { + if (primaryLabel) primaryLabel.textContent = primary.label; + primaryAction = primary.run; + } else { + primaryAction = () => {}; + } + show(primaryBtn, !!primary); + + const secondary = new Set(plan.secondary); + Object.keys(items).forEach((id) => show(items[id], secondary.has(id))); + show(caretBtn, plan.secondary.length > 0); + if (plan.secondary.length === 0) setMenuOpen(false); + + // Hide the whole action cluster when there is nothing to do. + show(prWrap, !!primary || plan.secondary.length > 0); + + bar.hidden = false; + } + + // Synchronous first paint from the server-stamped data attributes (branch, + // default, has-changes) so the control is correct immediately. The async + // refresh() then fills in PR state (which can require a network gh call). + function infoFromDataset() { + if (bar.dataset.gitRepo !== 'true') return null; + return { + isRepo: true, + branch: bar.dataset.gitBranch || '', + isDefault: bar.dataset.gitDefault === 'true', + hasChanges: bar.dataset.gitHasChanges === 'true', + prUrl: '', + prCreateUrl: '' + }; + } + + function refresh() { + return gitApi + .getGitInfo(sessionId) + .then(applyInfo) + .catch(() => {}); + } + + // ── Branch rename ── + function openEditor() { + if (!input) return; + input.value = currentBranch; + input.hidden = false; + if (nameEl) nameEl.hidden = true; + if (editBtn) editBtn.hidden = true; + input.focus(); + input.select(); + } + function closeEditor() { + if (!input) return; + input.hidden = true; + if (nameEl) nameEl.hidden = false; + if (editBtn) editBtn.hidden = false; + } + function commitRename() { + const next = (input ? input.value : '').trim(); + if (!next || next === currentBranch) { + closeEditor(); + return; + } + gitApi + .renameBranch(sessionId, next) + .then(() => { + closeEditor(); + return refresh(); + }) + .catch((err) => { + if (input) { + input.title = (err && err.message) || 'Rename failed'; + input.focus(); + input.select(); + } + }); + } + + if (editBtn) { + editBtn.addEventListener('click', (e) => { + e.preventDefault(); + openEditor(); + }); + } + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closeEditor(); + } + }); + input.addEventListener('blur', () => closeEditor()); + } + + // ── Split button ── + function setMenuOpen(open) { + if (!prMenu || !caretBtn) return; + prMenu.hidden = !open; + caretBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + if (primaryBtn) { + primaryBtn.addEventListener('click', (e) => { + e.preventDefault(); + setMenuOpen(false); + primaryAction(); + }); + } + if (caretBtn) { + caretBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(prMenu ? prMenu.hidden : false); + }); + } + documentImpl.addEventListener('click', (e) => { + if (prMenu && !prMenu.hidden && prWrap && !prWrap.contains(e.target)) setMenuOpen(false); + }); + + Object.keys(items).forEach((id) => { + const el = items[id]; + if (!el) return; + el.addEventListener('click', (e) => { + e.preventDefault(); + setMenuOpen(false); + ACTIONS[id].run(); + }); + }); + + const initial = infoFromDataset(); + if (initial) applyInfo(initial); + refresh(); + return { refresh }; +} diff --git a/web/src/session/chat/git-footer.test.js b/web/src/session/chat/git-footer.test.js new file mode 100644 index 0000000..ef06315 --- /dev/null +++ b/web/src/session/chat/git-footer.test.js @@ -0,0 +1,157 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { setupGitFooter, DRAFT_PR_PROMPT, COMMIT_PUSH_PROMPT, MERGE_PR_PROMPT } from './git-footer.js'; + +function createDom(dataset = {}) { + const div = document.createElement('div'); + div.innerHTML = ` + + + `; + document.body.appendChild(div); + const bar = div.querySelector('#pi-git-bar'); + Object.entries(dataset).forEach(([k, v]) => { bar.dataset[k] = v; }); + return div; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); +const id = (x) => document.getElementById(x); + +let dom; +afterEach(() => { + if (dom) dom.remove(); + dom = undefined; +}); + +describe('setupGitFooter', () => { + it('stays hidden when the cwd is not a git repo', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: false }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-bar').hidden).toBe(true); + }); + + it('renders synchronously from data attributes before the async fetch resolves', () => { + dom = createDom({ gitRepo: 'true', gitBranch: 'feature/x', gitDefault: 'false', gitHasChanges: 'false' }); + // getGitInfo never resolves during this synchronous assertion. + const gitApi = { getGitInfo: vi.fn(() => new Promise(() => {})) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + expect(id('pi-git-bar').hidden).toBe(false); + expect(id('pi-git-branch-name').textContent).toBe('feature/x'); + expect(id('pi-git-primary-label').textContent).toBe('Create PR'); + }); + + it('feature branch, no PR -> primary Create PR (commit+push+create), only manual under the caret', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: true, prUrl: '' }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('Create PR'); + expect(id('pi-git-branch-edit').hidden).toBe(false); + expect(id('pi-git-caret').hidden).toBe(false); + expect(id('pi-git-pr-manual').hidden).toBe(false); + // No separate Commit & push — Create PR already commits and pushes. + expect(id('pi-git-pr-commit').hidden).toBe(true); + expect(id('pi-git-pr-view').hidden).toBe(true); + expect(id('pi-git-pr-merge').hidden).toBe(true); + id('pi-git-primary').click(); + expect(id('pi-chat-message').value).toBe(DRAFT_PR_PROMPT); + }); + + it('feature branch, open PR + local changes -> primary Commit & push, secondary view + merge', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: true, prUrl: 'https://github.com/o/r/pull/42' }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('Commit & push'); + expect(id('pi-git-pr-view').hidden).toBe(false); + expect(id('pi-git-pr-merge').hidden).toBe(false); + expect(id('pi-git-pr-draft').hidden).toBe(true); + expect(id('pi-git-pr-manual').hidden).toBe(true); + id('pi-git-primary').click(); + expect(id('pi-chat-message').value).toBe(COMMIT_PUSH_PROMPT); + }); + + it('feature branch, open PR + no changes -> primary View PR, secondary merge only (no commit)', async () => { + const open = vi.fn(); + dom = createDom(); + const win = { ...window, open, Event: window.Event }; + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: false, prUrl: 'https://github.com/o/r/pull/42' }) }; + setupGitFooter({ documentImpl: document, windowImpl: win, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('View PR ↗'); + expect(id('pi-git-pr-merge').hidden).toBe(false); + expect(id('pi-git-pr-commit').hidden).toBe(true); + id('pi-git-primary').click(); + expect(open).toHaveBeenCalledWith('https://github.com/o/r/pull/42', '_blank', 'noopener'); + }); + + it('default branch + changes -> primary Commit & push, no caret, no edit pencil', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'main', isDefault: true, hasChanges: true }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-primary-label').textContent).toBe('Commit & push'); + expect(id('pi-git-caret').hidden).toBe(true); + expect(id('pi-git-branch-edit').hidden).toBe(true); + id('pi-git-primary').click(); + expect(id('pi-chat-message').value).toBe(COMMIT_PUSH_PROMPT); + }); + + it('default branch + no changes -> action control hidden, only the branch shows', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'main', isDefault: true, hasChanges: false }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + expect(id('pi-git-bar').hidden).toBe(false); + expect(id('pi-git-pr').hidden).toBe(true); + expect(id('pi-git-primary').hidden).toBe(true); + }); + + it('menu items run their actions (Merge PR injects merge prompt)', async () => { + dom = createDom(); + const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: true, branch: 'feature/x', isDefault: false, hasChanges: true, prUrl: 'https://github.com/o/r/pull/42' }) }; + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); + await flush(); + id('pi-git-pr-merge').click(); + expect(id('pi-chat-message').value).toBe(MERGE_PR_PROMPT); + }); + + it('renames the branch and refreshes', async () => { + dom = createDom(); + const renameBranch = vi.fn().mockResolvedValue({ ok: true, branch: 'renamed' }); + const getGitInfo = vi + .fn() + .mockResolvedValueOnce({ isRepo: true, branch: 'old', isDefault: false, prUrl: '' }) + .mockResolvedValueOnce({ isRepo: true, branch: 'renamed', isDefault: false, prUrl: '' }); + setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi: { getGitInfo, renameBranch } }); + await flush(); + + id('pi-git-branch-edit').click(); + const input = id('pi-git-branch-input'); + expect(input.hidden).toBe(false); + input.value = 'renamed'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await flush(); + + expect(renameBranch).toHaveBeenCalledWith('s', 'renamed'); + expect(id('pi-git-branch-name').textContent).toBe('renamed'); + }); +}); diff --git a/web/src/session/session.js b/web/src/session/session.js index 5309fc8..82e14f8 100644 --- a/web/src/session/session.js +++ b/web/src/session/session.js @@ -16,6 +16,8 @@ import { setupSessionUi } from './ui/session-ui-runner.js'; import * as chatComposerRunner from './chat/chat-composer-runner.js'; import * as doneNotifier from './chat/done-notifier.js'; import * as chatApi from './chat/chat-api.js'; +import * as gitApi from './chat/git-api.js'; +import { setupGitFooter } from './chat/git-footer.js'; import * as chatSelectors from './chat/chat-selectors.js'; import * as thinkingSelector from './chat/thinking-selector.js'; import * as modelSelector from './chat/model-selector.js'; @@ -442,6 +444,13 @@ export function runSessionApp({ target = window } = {}) { setIntervalImpl: target.setInterval.bind(target) }); + setupGitFooter({ + documentImpl, + windowImpl: target, + sessionId: getSessionSearchParams({ documentImpl, windowImpl: target }).get('id') || '', + gitApi + }); + // Handle Visual Viewport changes to prevent mobile browsers from shifting // the top fixed header out of view when the virtual keyboard is open. if (target.visualViewport) {