shelley: improve directory picker UX

Philip Zeyliger and Shelley created

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 <shelley@exe.dev>

Change summary

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(-)

Detailed changes

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)
 		}
 	})
 }

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 {

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 {

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;

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<string>("");
+
   // 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 && (
-            <div className="directory-picker-current">
-              {displayDir.path}
-              {filterPrefix && <span className="directory-picker-filter">/{filterPrefix}*</span>}
+            <div
+              className={`directory-picker-current${displayDir.git_head_subject ? " directory-picker-current-git" : ""}`}
+            >
+              <span className="directory-picker-current-path">
+                {displayDir.path}
+                {filterPrefix && <span className="directory-picker-filter">/{filterPrefix}*</span>}
+              </span>
+              {displayDir.git_head_subject && (
+                <span
+                  className="directory-picker-current-subject"
+                  title={displayDir.git_head_subject}
+                >
+                  {displayDir.git_head_subject}
+                </span>
+              )}
             </div>
           )}
 
@@ -360,7 +383,7 @@ function DirectoryPickerModal({
               {filteredEntries.map((entry) => (
                 <button
                   key={entry.name}
-                  className="directory-picker-entry"
+                  className={`directory-picker-entry${entry.git_head_subject ? " directory-picker-entry-git" : ""}`}
                   onClick={() => handleEntryClick(entry)}
                 >
                   <svg
@@ -376,7 +399,7 @@ function DirectoryPickerModal({
                       d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
                     />
                   </svg>
-                  <span>
+                  <span className="directory-picker-entry-name">
                     {filterPrefix &&
                     entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase()) ? (
                       <>
@@ -387,6 +410,11 @@ function DirectoryPickerModal({
                       entry.name
                     )}
                   </span>
+                  {entry.git_head_subject && (
+                    <span className="directory-picker-git-subject" title={entry.git_head_subject}>
+                      {entry.git_head_subject}
+                    </span>
+                  )}
                 </button>
               ))}
 

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,
       }}
     >

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

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;