sheley: Add create directory feature to Select Directory modal

Philip Zeyliger and Shelley created

Prompt: Add a way to create a directory in the "Select Directory" modal

- Add POST /api/create-directory endpoint that creates directories with proper error handling
- Add createDirectory() method to frontend API service
- Add inline create form in DirectoryPickerModal with:
  - New Folder button in footer
  - Input field with submit/cancel buttons
  - Error message display
  - Auto-navigation to newly created folder
- Add CSS styles for the create form and related elements

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

server/server.go                           |  70 +++++++++
ui/src/components/DirectoryPickerModal.tsx | 175 +++++++++++++++++++++++
ui/src/services/api.ts                     |  12 +
ui/src/styles.css                          | 102 +++++++++++++
4 files changed, 356 insertions(+), 3 deletions(-)

Detailed changes

server/server.go 🔗

@@ -256,6 +256,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
 	mux.Handle("/api/conversation-by-slug/", gzipHandler(http.HandlerFunc(s.handleConversationBySlug)))
 	mux.Handle("/api/validate-cwd", http.HandlerFunc(s.handleValidateCwd)) // Small response
 	mux.Handle("/api/list-directory", gzipHandler(http.HandlerFunc(s.handleListDirectory)))
+	mux.Handle("/api/create-directory", http.HandlerFunc(s.handleCreateDirectory))
 	mux.Handle("/api/git/diffs", gzipHandler(http.HandlerFunc(s.handleGitDiffs)))
 	mux.Handle("/api/git/diffs/", gzipHandler(http.HandlerFunc(s.handleGitDiffFiles)))
 	mux.Handle("/api/git/file-diff/", gzipHandler(http.HandlerFunc(s.handleGitFileDiff)))
@@ -448,6 +449,75 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(response)
 }
 
+// handleCreateDirectory creates a new directory
+func (s *Server) handleCreateDirectory(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req struct {
+		Path string `json:"path"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"error": "invalid request body",
+		})
+		return
+	}
+
+	if req.Path == "" {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"error": "path is required",
+		})
+		return
+	}
+
+	// Clean the path
+	path := filepath.Clean(req.Path)
+
+	// Check if path already exists
+	if _, err := os.Stat(path); err == nil {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"error": "path already exists",
+		})
+		return
+	}
+
+	// Verify parent directory exists
+	parentDir := filepath.Dir(path)
+	if _, err := os.Stat(parentDir); os.IsNotExist(err) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"error": "parent directory does not exist",
+		})
+		return
+	}
+
+	// Create the directory (only the final directory, not parents)
+	if err := os.Mkdir(path, 0o755); err != nil {
+		w.Header().Set("Content-Type", "application/json")
+		if os.IsPermission(err) {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"error": "permission denied",
+			})
+		} else {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"error": err.Error(),
+			})
+		}
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"path": path,
+	})
+}
+
 // getOrCreateConversationManager gets an existing conversation manager or creates a new one.
 func (s *Server) getOrCreateConversationManager(ctx context.Context, conversationID string) (*ConversationManager, error) {
 	manager, err, _ := s.conversationGroup.Do(conversationID, func() (*ConversationManager, error) {

ui/src/components/DirectoryPickerModal.tsx 🔗

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useRef, useCallback } from "react";
+import React, { useState, useEffect, useRef, useCallback, useId } from "react";
 import { api } from "../services/api";
 
 interface DirectoryEntry {
@@ -33,6 +33,14 @@ function DirectoryPickerModal({
   const [error, setError] = useState<string | null>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
+  // State for create directory mode
+  const [isCreating, setIsCreating] = useState(false);
+  const [newDirName, setNewDirName] = useState("");
+  const [createError, setCreateError] = useState<string | null>(null);
+  const [createLoading, setCreateLoading] = useState(false);
+  const newDirInputRef = useRef<HTMLInputElement>(null);
+  const createInputId = useId();
+
   // Cache for directory listings
   const cacheRef = useRef<Map<string, CachedDirectory>>(new Map());
 
@@ -190,6 +198,75 @@ function DirectoryPickerModal({
     onClose();
   };
 
+  // Focus the new directory input when entering create mode
+  useEffect(() => {
+    if (isCreating && newDirInputRef.current) {
+      newDirInputRef.current.focus();
+    }
+  }, [isCreating]);
+
+  const handleStartCreate = () => {
+    setIsCreating(true);
+    setNewDirName("");
+    setCreateError(null);
+  };
+
+  const handleCancelCreate = () => {
+    setIsCreating(false);
+    setNewDirName("");
+    setCreateError(null);
+  };
+
+  const handleCreateDirectory = async () => {
+    if (!newDirName.trim()) {
+      setCreateError("Directory name is required");
+      return;
+    }
+
+    // Validate directory name (no path separators or special chars)
+    if (newDirName.includes("/") || newDirName.includes("\\")) {
+      setCreateError("Directory name cannot contain slashes");
+      return;
+    }
+
+    const basePath = displayDir?.path || "/";
+    const newPath = basePath === "/" ? `/${newDirName}` : `${basePath}/${newDirName}`;
+
+    setCreateLoading(true);
+    setCreateError(null);
+
+    try {
+      const result = await api.createDirectory(newPath);
+      if (result.error) {
+        setCreateError(result.error);
+        return;
+      }
+
+      // Clear the cache for the current directory so it reloads with the new dir
+      cacheRef.current.delete(basePath);
+
+      // Exit create mode and navigate to the new directory
+      setIsCreating(false);
+      setNewDirName("");
+      setInputPath(newPath + "/");
+    } catch (err) {
+      setCreateError(err instanceof Error ? err.message : "Failed to create directory");
+    } finally {
+      setCreateLoading(false);
+    }
+  };
+
+  const handleCreateKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.nativeEvent.isComposing) return;
+    if (e.key === "Enter") {
+      e.preventDefault();
+      handleCreateDirectory();
+    } else if (e.key === "Escape") {
+      e.preventDefault();
+      handleCancelCreate();
+    }
+  };
+
   const handleBackdropClick = (e: React.MouseEvent) => {
     if (e.target === e.currentTarget) {
       onClose();
@@ -313,8 +390,78 @@ function DirectoryPickerModal({
                 </button>
               ))}
 
+              {/* Create new directory inline form */}
+              {isCreating && (
+                <div className="directory-picker-create-form">
+                  <svg
+                    fill="none"
+                    stroke="currentColor"
+                    viewBox="0 0 24 24"
+                    className="directory-picker-icon"
+                  >
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={2}
+                      d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
+                    />
+                  </svg>
+                  <label htmlFor={createInputId} className="sr-only">
+                    New folder name
+                  </label>
+                  <input
+                    id={createInputId}
+                    ref={newDirInputRef}
+                    type="text"
+                    value={newDirName}
+                    onChange={(e) => setNewDirName(e.target.value)}
+                    onKeyDown={handleCreateKeyDown}
+                    placeholder="New folder name"
+                    className="directory-picker-create-input"
+                    disabled={createLoading}
+                  />
+                  <button
+                    className="directory-picker-create-btn"
+                    onClick={handleCreateDirectory}
+                    disabled={createLoading || !newDirName.trim()}
+                    title="Create"
+                  >
+                    {createLoading ? (
+                      <div className="spinner spinner-small"></div>
+                    ) : (
+                      <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path
+                          strokeLinecap="round"
+                          strokeLinejoin="round"
+                          strokeWidth={2}
+                          d="M5 13l4 4L19 7"
+                        />
+                      </svg>
+                    )}
+                  </button>
+                  <button
+                    className="directory-picker-create-btn directory-picker-cancel-btn"
+                    onClick={handleCancelCreate}
+                    disabled={createLoading}
+                    title="Cancel"
+                  >
+                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M6 18L18 6M6 6l12 12"
+                      />
+                    </svg>
+                  </button>
+                </div>
+              )}
+
+              {/* Create error message */}
+              {createError && <div className="directory-picker-create-error">{createError}</div>}
+
               {/* Empty state */}
-              {filteredEntries.length === 0 && !showParent && (
+              {filteredEntries.length === 0 && !showParent && !isCreating && (
                 <div className="directory-picker-empty">
                   {filterPrefix ? "No matching directories" : "No subdirectories"}
                 </div>
@@ -325,6 +472,30 @@ function DirectoryPickerModal({
 
         {/* Footer */}
         <div className="directory-picker-footer">
+          {/* New Folder button */}
+          {!isCreating && !loading && !error && (
+            <button
+              className="btn directory-picker-new-btn"
+              onClick={handleStartCreate}
+              title="Create new folder"
+            >
+              <svg
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+                className="directory-picker-new-icon"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
+                />
+              </svg>
+              New Folder
+            </button>
+          )}
+          <div className="directory-picker-footer-spacer"></div>
           <button className="btn" onClick={onClose}>
             Cancel
           </button>

ui/src/services/api.ts 🔗

@@ -118,6 +118,18 @@ class ApiService {
     return response.json();
   }
 
+  async createDirectory(path: string): Promise<{ path?: string; error?: string }> {
+    const response = await fetch(`${this.baseUrl}/create-directory`, {
+      method: "POST",
+      headers: this.postHeaders,
+      body: JSON.stringify({ path }),
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to create directory: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
   async getArchivedConversations(): Promise<Conversation[]> {
     const response = await fetch(`${this.baseUrl}/conversations/archived`);
     if (!response.ok) {

ui/src/styles.css 🔗

@@ -2834,12 +2834,112 @@ svg {
 
 .directory-picker-footer {
   display: flex;
-  justify-content: flex-end;
+  align-items: center;
   gap: 0.5rem;
   padding: 1rem;
   border-top: 1px solid var(--border);
 }
 
+.directory-picker-footer-spacer {
+  flex: 1;
+}
+
+.directory-picker-new-btn {
+  display: flex;
+  align-items: center;
+  gap: 0.375rem;
+}
+
+.directory-picker-new-icon {
+  width: 1rem;
+  height: 1rem;
+}
+
+/* Create directory form inside the list */
+.directory-picker-create-form {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.5rem 0.75rem;
+  background: var(--bg-tertiary);
+  border-bottom: 1px solid var(--border);
+}
+
+.directory-picker-create-input {
+  flex: 1;
+  padding: 0.375rem 0.5rem;
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+}
+
+.directory-picker-create-input:focus {
+  outline: none;
+  border-color: var(--primary);
+}
+
+.directory-picker-create-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 1.75rem;
+  height: 1.75rem;
+  padding: 0;
+  border: none;
+  border-radius: 0.25rem;
+  background: var(--primary);
+  color: white;
+  cursor: pointer;
+  transition: background-color 0.15s;
+}
+
+.directory-picker-create-btn:hover:not(:disabled) {
+  background: var(--primary-hover);
+}
+
+.directory-picker-create-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.directory-picker-create-btn svg {
+  width: 1rem;
+  height: 1rem;
+}
+
+.directory-picker-cancel-btn {
+  background: var(--bg-secondary);
+  color: var(--text-secondary);
+}
+
+.directory-picker-cancel-btn:hover:not(:disabled) {
+  background: var(--bg-tertiary);
+}
+
+.directory-picker-create-error {
+  padding: 0.375rem 0.75rem;
+  background: var(--error-bg);
+  color: var(--error-text);
+  font-size: 0.75rem;
+  border-bottom: 1px solid var(--error-border);
+}
+
+/* Screen reader only */
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+
 /* Context Usage Bar */
 /* Status bar for active conversation */
 .status-bar-active {