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;