From 7d0b8bc511420b04c814d88a9cc12fe78c75d056 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Sun, 25 Jan 2026 09:43:19 -0800 Subject: [PATCH] shelley: improve directory picker UX Three improvements: 1. Include hidden directories (starting with '.') in directory listing Previously these were filtered out, but they're useful for accessing .config, .local, .git subdirectories, etc. 2. Show git HEAD commit subject for git repository roots When a directory contains a .git subdirectory or .git file (worktree), we run 'git log -1 --format=%s' to get the HEAD commit subject and display it. Git repos get an orange folder icon and show the subject on the right side (truncated with ellipsis if needed). The current directory also shows its git subject if it's a repo. 3. Use current conversation's cwd when starting new conversation When clicking '+' to create a new conversation, we now save the current conversation's cwd to localStorage so the new conversation inherits it. This avoids having to re-select the directory for related work. 4. Fix race condition in directory loading Added tracking of expected path to prevent stale directory data from being displayed when navigating quickly. Prompt: In the "dir selection" box in shelley, let's (a) include "hidden" dirs that start with dot, and (b) indicate git repo roots by including the subject of their HEAD commit if we can get it. In addition, when a user clicks "+" for "New Conversation", let's use the dir of the conversation they're on as the starting point. Co-authored-by: Shelley --- server/cwd_test.go | 227 ++++++++++++++++++++- server/server.go | 70 ++++++- ui/src/App.tsx | 11 +- ui/src/components/ChatInterface.tsx | 7 + ui/src/components/DirectoryPickerModal.tsx | 40 +++- ui/src/components/MessageActionBar.tsx | 3 +- ui/src/services/api.ts | 3 +- ui/src/styles.css | 40 ++++ 8 files changed, 372 insertions(+), 29 deletions(-) diff --git a/server/cwd_test.go b/server/cwd_test.go index 4bcc1e3bab35f0423fa96621b259c3a482b3aac1..067ecf58e27e17b1af77da67b2ada908cedfdf64 100644 --- a/server/cwd_test.go +++ b/server/cwd_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -207,7 +208,7 @@ func TestListDirectory(t *testing.T) { } }) - t.Run("hidden_directories_excluded", func(t *testing.T) { + t.Run("hidden_directories_included", func(t *testing.T) { // Create a temp directory with a hidden directory tmpDir, err := os.MkdirTemp("", "listdir_hidden_test") if err != nil { @@ -240,13 +241,227 @@ func TestListDirectory(t *testing.T) { t.Fatalf("failed to parse response: %v", err) } - // Should only include the visible directory, not the hidden one - if len(resp.Entries) != 1 { - t.Errorf("expected 1 entry, got: %d", len(resp.Entries)) + // Should include both visible and hidden directories + if len(resp.Entries) != 2 { + t.Errorf("expected 2 entries, got: %d", len(resp.Entries)) + } + + // Check that both directories are present (sorted alphabetically, hidden first) + names := make(map[string]bool) + for _, e := range resp.Entries { + names[e.Name] = true + } + if !names[".hidden"] { + t.Errorf("expected .hidden to be included") + } + if !names["visible"] { + t.Errorf("expected visible to be included") + } + }) + + t.Run("git_repo_head_subject", func(t *testing.T) { + // Create a temp directory containing a git repo + tmpDir, err := os.MkdirTemp("", "listdir_git_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a subdirectory that will be a git repo + repoDir := tmpDir + "/myrepo" + if err := os.Mkdir(repoDir, 0o755); err != nil { + t.Fatalf("failed to create repo dir: %v", err) + } + + // Initialize git repo and create a commit + cmd := exec.Command("git", "init") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git: %v", err) + } + + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to config git email: %v", err) + } + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to config git name: %v", err) + } + + // Create a file and commit it + if err := os.WriteFile(repoDir+"/README.md", []byte("# Hello"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + cmd = exec.Command("git", "add", "README.md") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "Test commit subject line\n\nPrompt: test") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } + + // Create another directory that is not a git repo + nonRepoDir := tmpDir + "/notarepo" + if err := os.Mkdir(nonRepoDir, 0o755); err != nil { + t.Fatalf("failed to create non-repo dir: %v", err) + } + + req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil) + w := httptest.NewRecorder() + h.server.handleListDirectory(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp ListDirectoryResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Entries) != 2 { + t.Fatalf("expected 2 entries, got: %d", len(resp.Entries)) + } + + // Find the git repo entry and verify it has the commit subject + var gitEntry, nonGitEntry *DirectoryEntry + for i := range resp.Entries { + if resp.Entries[i].Name == "myrepo" { + gitEntry = &resp.Entries[i] + } else if resp.Entries[i].Name == "notarepo" { + nonGitEntry = &resp.Entries[i] + } + } + + if gitEntry == nil { + t.Fatal("expected to find myrepo entry") + } + if nonGitEntry == nil { + t.Fatal("expected to find notarepo entry") + } + + // Git repo should have the HEAD commit subject + if gitEntry.GitHeadSubject != "Test commit subject line" { + t.Errorf("expected git_head_subject 'Test commit subject line', got: %q", gitEntry.GitHeadSubject) + } + + // Non-git dir should not have a subject + if nonGitEntry.GitHeadSubject != "" { + t.Errorf("expected empty git_head_subject for non-git dir, got: %q", nonGitEntry.GitHeadSubject) + } + }) + + t.Run("git_worktree_head_subject", func(t *testing.T) { + // Create a temp directory containing a git repo and a worktree + tmpDir, err := os.MkdirTemp("", "listdir_worktree_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a main git repo + mainRepo := tmpDir + "/main-repo" + if err := os.Mkdir(mainRepo, 0o755); err != nil { + t.Fatalf("failed to create main repo dir: %v", err) + } + + // Initialize git repo and create a commit + cmd := exec.Command("git", "init") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git: %v", err) + } + + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("failed to config git email: %v", err) + } + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("failed to config git name: %v", err) + } + + // Create a file and commit it + if err := os.WriteFile(mainRepo+"/README.md", []byte("# Hello"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + cmd = exec.Command("git", "add", "README.md") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "Main repo commit\n\nPrompt: test") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } + + // Create a branch and worktree + cmd = exec.Command("git", "branch", "feature-branch") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + + worktreePath := tmpDir + "/worktree-dir" + cmd = exec.Command("git", "worktree", "add", worktreePath, "feature-branch") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + // Verify the worktree has a .git file (not directory) + gitPath := worktreePath + "/.git" + fi, err := os.Stat(gitPath) + if err != nil { + t.Fatalf("failed to stat worktree .git: %v", err) + } + if fi.IsDir() { + t.Fatalf("expected .git to be a file for worktree, got directory") + } + + req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil) + w := httptest.NewRecorder() + h.server.handleListDirectory(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp ListDirectoryResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Find the worktree entry and verify it has the commit subject + var worktreeEntry *DirectoryEntry + for i := range resp.Entries { + if resp.Entries[i].Name == "worktree-dir" { + worktreeEntry = &resp.Entries[i] + } + } + + if worktreeEntry == nil { + t.Fatal("expected to find worktree-dir entry") } - if len(resp.Entries) > 0 && resp.Entries[0].Name != "visible" { - t.Errorf("expected entry 'visible', got: %s", resp.Entries[0].Name) + // Worktree should have the HEAD commit subject + if worktreeEntry.GitHeadSubject != "Main repo commit" { + t.Errorf("expected git_head_subject 'Main repo commit', got: %q", worktreeEntry.GitHeadSubject) } }) } diff --git a/server/server.go b/server/server.go index 53cec6ed8be2a930343ec3a0011b10a524f6c50b..00ac56b9a63120b77ed05a015fddc38f405d304b 100644 --- a/server/server.go +++ b/server/server.go @@ -342,15 +342,17 @@ func (s *Server) handleValidateCwd(w http.ResponseWriter, r *http.Request) { // DirectoryEntry represents a single directory entry for the directory picker type DirectoryEntry struct { - Name string `json:"name"` - IsDir bool `json:"is_dir"` + Name string `json:"name"` + IsDir bool `json:"is_dir"` + GitHeadSubject string `json:"git_head_subject,omitempty"` } // ListDirectoryResponse is the response from the list-directory endpoint type ListDirectoryResponse struct { - Path string `json:"path"` - Parent string `json:"parent"` - Entries []DirectoryEntry `json:"entries"` + Path string `json:"path"` + Parent string `json:"parent"` + Entries []DirectoryEntry `json:"entries"` + GitHeadSubject string `json:"git_head_subject,omitempty"` } // handleListDirectory lists the contents of a directory for the directory picker @@ -421,16 +423,22 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) { // Build response with only directories (for directory picker) var entries []DirectoryEntry for _, entry := range dirEntries { - // Skip hidden files/directories (starting with .) - if strings.HasPrefix(entry.Name(), ".") { - continue - } // Only include directories if entry.IsDir() { - entries = append(entries, DirectoryEntry{ + dirEntry := DirectoryEntry{ Name: entry.Name(), IsDir: true, - }) + } + + // Check if this is a git repo root and get HEAD commit subject + entryPath := filepath.Join(path, entry.Name()) + if isGitRepo(entryPath) { + if subject := getGitHeadSubject(entryPath); subject != "" { + dirEntry.GitHeadSubject = subject + } + } + + entries = append(entries, dirEntry) } } @@ -447,10 +455,50 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) { Entries: entries, } + // Check if the current directory itself is a git repo + if isGitRepo(path) { + response.GitHeadSubject = getGitHeadSubject(path) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } +// getGitHeadSubject returns the subject line of HEAD commit for a git repository. +// Returns empty string if unable to get the subject. +// isGitRepo checks if the given path is a git repository root. +// Returns true for both regular repos (.git directory) and worktrees (.git file with gitdir:). +func isGitRepo(dirPath string) bool { + gitPath := filepath.Join(dirPath, ".git") + fi, err := os.Stat(gitPath) + if err != nil { + return false + } + if fi.IsDir() { + return true // regular .git directory + } + if fi.Mode().IsRegular() { + // Check if it's a worktree .git file + content, err := os.ReadFile(gitPath) + if err == nil && strings.HasPrefix(string(content), "gitdir:") { + return true + } + } + return false +} + +// getGitHeadSubject returns the subject line of HEAD commit for a git repository. +// Returns empty string if unable to get the subject. +func getGitHeadSubject(repoPath string) string { + cmd := exec.Command("git", "log", "-1", "--format=%s") + cmd.Dir = repoPath + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + // handleCreateDirectory creates a new directory func (s *Server) handleCreateDirectory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0b699b2f310d880fd9e0151bc22c3ff3802891ec..7efec56c19d7a2edb0aa4d11e33ea633141522b4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -215,7 +215,11 @@ function App() { }; const startNewConversation = () => { - // Just clear the current conversation - a new one will be created when the user sends their first message + // Save the current conversation's cwd to localStorage so the new conversation picks it up + if (currentConversation?.cwd) { + localStorage.setItem("shelley_selected_cwd", currentConversation.cwd); + } + // Clear the current conversation - a new one will be created when the user sends their first message setCurrentConversationId(null); setDrawerOpen(false); }; @@ -298,8 +302,9 @@ function App() { (conv) => conv.conversation_id === currentConversationId, ); - // Get the CWD from the most recent conversation (first in list, sorted by updated_at desc) - const mostRecentCwd = conversations.length > 0 ? conversations[0].cwd : null; + // Get the CWD from the current conversation, or fall back to the most recent conversation + const mostRecentCwd = + currentConversation?.cwd || (conversations.length > 0 ? conversations[0].cwd : null); const handleFirstMessage = async (message: string, model: string, cwd?: string) => { try { diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index 8d7dc6d10e8c311d852eb9715142cf6c8c335310..2d3a0611149c17370751679a41becd343130a34b 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -510,6 +510,13 @@ function ChatInterface({ localStorage.setItem("shelley_selected_cwd", cwd); }; + // Reset cwdInitialized when switching to a new conversation so we re-read from localStorage + useEffect(() => { + if (conversationId === null) { + setCwdInitialized(false); + } + }, [conversationId]); + // Initialize CWD with priority: localStorage > mostRecentCwd > server default useEffect(() => { if (cwdInitialized) return; diff --git a/ui/src/components/DirectoryPickerModal.tsx b/ui/src/components/DirectoryPickerModal.tsx index eea5e7420dbc04858a5c2202d0858af3967882f3..07d68f59bf89c4274ac62668951d42b8ad5eede6 100644 --- a/ui/src/components/DirectoryPickerModal.tsx +++ b/ui/src/components/DirectoryPickerModal.tsx @@ -4,12 +4,14 @@ import { api } from "../services/api"; interface DirectoryEntry { name: string; is_dir: boolean; + git_head_subject?: string; } interface CachedDirectory { path: string; parent: string; entries: DirectoryEntry[]; + git_head_subject?: string; } interface DirectoryPickerModalProps { @@ -100,6 +102,7 @@ function DirectoryPickerModal({ path: result.path, parent: result.parent, entries: result.entries || [], + git_head_subject: result.git_head_subject, }; // Cache it @@ -114,6 +117,9 @@ function DirectoryPickerModal({ } }, []); + // Track the current expected path to avoid race conditions + const expectedPathRef = useRef(""); + // Update display when input changes useEffect(() => { if (!isOpen) return; @@ -121,9 +127,14 @@ function DirectoryPickerModal({ const { dirPath, prefix } = parseInputPath(inputPath); setFilterPrefix(prefix); + // Track which path we expect to display + const normalizedDirPath = dirPath || "/"; + expectedPathRef.current = normalizedDirPath; + // Load the directory loadDirectory(dirPath).then((dir) => { - if (dir) { + // Only update if this is still the path we want + if (dir && expectedPathRef.current === normalizedDirPath) { setDisplayDir(dir); setError(null); } @@ -313,9 +324,21 @@ function DirectoryPickerModal({ {/* Current directory indicator */} {displayDir && ( -
- {displayDir.path} - {filterPrefix && /{filterPrefix}*} +
+ + {displayDir.path} + {filterPrefix && /{filterPrefix}*} + + {displayDir.git_head_subject && ( + + {displayDir.git_head_subject} + + )}
)} @@ -360,7 +383,7 @@ function DirectoryPickerModal({ {filteredEntries.map((entry) => ( ))} diff --git a/ui/src/components/MessageActionBar.tsx b/ui/src/components/MessageActionBar.tsx index bcfdcbd2ba64002c49f4f55999b83a1e2750ba0e..b2dbe00f299a9c4f13340389957fc657686f7396 100644 --- a/ui/src/components/MessageActionBar.tsx +++ b/ui/src/components/MessageActionBar.tsx @@ -30,7 +30,7 @@ function MessageActionBar({ onCopy, onShowUsage }: MessageActionBarProps) { data-action-bar style={{ position: "absolute", - top: "-28px", + top: "8px", right: "8px", display: "flex", gap: "2px", @@ -38,7 +38,6 @@ function MessageActionBar({ onCopy, onShowUsage }: MessageActionBarProps) { border: "1px solid var(--border)", borderRadius: "4px", padding: "2px", - zIndex: 10, }} > diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index a6f9b952034688c11f305eb328bb2dab2ca39716..ca8f8e5bb6b1149c2f045f1235bd303d3430dcd6 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -125,7 +125,8 @@ class ApiService { async listDirectory(path?: string): Promise<{ path: string; parent: string; - entries: Array<{ name: string; is_dir: boolean }>; + entries: Array<{ name: string; is_dir: boolean; git_head_subject?: string }>; + git_head_subject?: string; error?: string; }> { const url = path diff --git a/ui/src/styles.css b/ui/src/styles.css index a7cd5838d271da280a6db45c16c14679a29a3f8f..b868846f0cba1d4b1b4cb495d15a5c6eda0b5d35 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2810,6 +2810,9 @@ svg { } .directory-picker-current { + display: flex; + align-items: center; + gap: 0.5rem; font-size: 0.75rem; color: var(--text-secondary); font-family: var(--font-mono); @@ -2817,6 +2820,24 @@ svg { padding: 0.25rem 0; } +.directory-picker-current-path { + flex-shrink: 0; +} + +.directory-picker-current-git .directory-picker-current-path { + color: var(--accent-color, #f97316); +} + +.directory-picker-current-subject { + flex: 1; + min-width: 0; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; +} + .directory-picker-filter { color: var(--primary); font-weight: 500; @@ -2887,6 +2908,25 @@ svg { color: var(--text-secondary); } +.directory-picker-entry-name { + flex-shrink: 0; +} + +.directory-picker-entry-git .directory-picker-icon { + color: var(--accent-color, #f97316); +} + +.directory-picker-git-subject { + flex: 1; + min-width: 0; + color: var(--text-tertiary); + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; +} + .directory-picker-empty { padding: 2rem; text-align: center;