shelley/ui: pass git worktree to diff viewer, add dir chooser

Philip Zeyliger and Shelley created

Prompt: Sometimes the diff view can get confused about what dir you're in. In the case that someone is clicking on a "diff" link that the agent reports after a git change, that should always work, since we can include the git repo dir in the hyperlink. We can also add a "dir" chooser (just a folder icon) that lets you navigate to a different git directory from the top bar. We can re-use the directory picker, probably, but we should filter to only show folders, not files, since git repositories are always folders.

Three changes:

1. When clicking a 'diff' link on a git commit message, the worktree
   path from the git state is now passed through to the DiffViewer,
   so it always opens in the correct directory regardless of the
   conversation's cwd.

2. Added a folder icon button to the DiffViewer header (both mobile
   and desktop) that opens a DirectoryPickerModal, allowing users to
   switch to a different git repository directory.

3. Added a 'foldersOnly' prop to DirectoryPickerModal that filters
   out non-directory entries, since git repositories are always
   directories.

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

Change summary

ui/src/components/ChatInterface.tsx        |  8 +++-
ui/src/components/DiffViewer.tsx           | 47 +++++++++++++++++++++++
ui/src/components/DirectoryPickerModal.tsx |  3 +
ui/src/components/Message.tsx              |  6 +-
ui/src/styles.css                          | 20 ++++++++++
5 files changed, 78 insertions(+), 6 deletions(-)

Detailed changes

ui/src/components/ChatInterface.tsx 🔗

@@ -591,6 +591,7 @@ function ChatInterface({
   const [diffViewerInitialCommit, setDiffViewerInitialCommit] = useState<string | undefined>(
     undefined,
   );
+  const [diffViewerCwd, setDiffViewerCwd] = useState<string | undefined>(undefined);
   const [diffCommentText, setDiffCommentText] = useState("");
   const [agentWorking, setAgentWorking] = useState(false);
   const [cancelling, setCancelling] = useState(false);
@@ -1350,8 +1351,9 @@ function ChatInterface({
           <MessageComponent
             key={item.message.message_id}
             message={item.message}
-            onOpenDiffViewer={(commit) => {
+            onOpenDiffViewer={(commit, cwd) => {
               setDiffViewerInitialCommit(commit);
+              setDiffViewerCwd(cwd);
               setShowDiffViewer(true);
             }}
             onCommentTextChange={setDiffCommentText}
@@ -1825,14 +1827,16 @@ function ChatInterface({
 
       {/* Diff Viewer */}
       <DiffViewer
-        cwd={currentConversation?.cwd || selectedCwd}
+        cwd={diffViewerCwd || currentConversation?.cwd || selectedCwd}
         isOpen={showDiffViewer}
         onClose={() => {
           setShowDiffViewer(false);
           setDiffViewerInitialCommit(undefined);
+          setDiffViewerCwd(undefined);
         }}
         onCommentTextChange={setDiffCommentText}
         initialCommit={diffViewerInitialCommit}
+        onCwdChange={setDiffViewerCwd}
       />
 
       {/* Version Checker Modal */}

ui/src/components/DiffViewer.tsx 🔗

@@ -3,6 +3,7 @@ import type * as Monaco from "monaco-editor";
 import { api } from "../services/api";
 import { isDarkModeActive } from "../services/theme";
 import { GitDiffInfo, GitFileInfo, GitFileDiff } from "../types";
+import DirectoryPickerModal from "./DirectoryPickerModal";
 
 interface DiffViewerProps {
   cwd: string;
@@ -10,6 +11,7 @@ interface DiffViewerProps {
   onClose: () => void;
   onCommentTextChange: (text: string) => void;
   initialCommit?: string; // If set, select this commit when opening
+  onCwdChange?: (cwd: string) => void; // Called when user picks a different git directory
 }
 
 // Icon components for cleaner JSX
@@ -79,9 +81,17 @@ function loadMonaco(): Promise<typeof Monaco> {
 
 type ViewMode = "comment" | "edit";
 
-function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }: DiffViewerProps) {
+function DiffViewer({
+  cwd,
+  isOpen,
+  onClose,
+  onCommentTextChange,
+  initialCommit,
+  onCwdChange,
+}: DiffViewerProps) {
   const [diffs, setDiffs] = useState<GitDiffInfo[]>([]);
   const [gitRoot, setGitRoot] = useState<string | null>(null);
+  const [showDirPicker, setShowDirPicker] = useState(false);
   const [selectedDiff, setSelectedDiff] = useState<string | null>(null);
   const [files, setFiles] = useState<GitFileInfo[]>([]);
   const [selectedFile, setSelectedFile] = useState<string | null>(null);
@@ -904,6 +914,27 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }
     </div>
   );
 
+  const dirButton = (
+    <button
+      className="diff-viewer-dir-btn"
+      onClick={() => setShowDirPicker(true)}
+      title={`Git directory: ${cwd}\nClick to change`}
+    >
+      <svg
+        width="16"
+        height="16"
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      >
+        <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
+      </svg>
+    </button>
+  );
+
   return (
     <div className="diff-viewer-overlay">
       <div className="diff-viewer-container">
@@ -929,6 +960,7 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }
               {commitSelector}
               {fileSelector}
             </div>
+            {dirButton}
             <button className="diff-viewer-close" onClick={onClose} title="Close (Esc)">
               ×
             </button>
@@ -944,6 +976,7 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }
               <div className="diff-viewer-controls-row">
                 {navButtons}
                 {modeToggle}
+                {dirButton}
                 <button className="diff-viewer-close" onClick={onClose} title="Close (Esc)">
                   ×
                 </button>
@@ -1072,6 +1105,18 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }
           </div>
         )}
       </div>
+
+      {/* Directory picker for changing git directory */}
+      <DirectoryPickerModal
+        isOpen={showDirPicker}
+        onClose={() => setShowDirPicker(false)}
+        onSelect={(path) => {
+          onCwdChange?.(path);
+          setShowDirPicker(false);
+        }}
+        initialPath={cwd}
+        foldersOnly
+      />
     </div>
   );
 }

ui/src/components/DirectoryPickerModal.tsx 🔗

@@ -20,6 +20,7 @@ interface DirectoryPickerModalProps {
   onClose: () => void;
   onSelect: (path: string) => void;
   initialPath?: string;
+  foldersOnly?: boolean; // If true, only show directories (hide files)
 }
 
 function DirectoryPickerModal({
@@ -27,6 +28,7 @@ function DirectoryPickerModal({
   onClose,
   onSelect,
   initialPath,
+  foldersOnly,
 }: DirectoryPickerModalProps) {
   const [inputPath, setInputPath] = useState(() => {
     if (!initialPath) return "";
@@ -173,6 +175,7 @@ function DirectoryPickerModal({
   // Filter entries based on prefix (case-insensitive)
   const filteredEntries =
     displayDir?.entries.filter((entry) => {
+      if (foldersOnly && !entry.is_dir) return false;
       if (!filterPrefix) return true;
       return entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase());
     }) || [];

ui/src/components/Message.tsx 🔗

@@ -28,7 +28,7 @@ interface ToolDisplay {
 
 interface MessageProps {
   message: MessageType;
-  onOpenDiffViewer?: (commit: string) => void;
+  onOpenDiffViewer?: (commit: string, cwd?: string) => void;
   onCommentTextChange?: (text: string) => void;
 }
 
@@ -72,7 +72,7 @@ function GitInfoMessage({
   onOpenDiffViewer,
 }: {
   message: MessageType;
-  onOpenDiffViewer?: (commit: string) => void;
+  onOpenDiffViewer?: (commit: string, cwd?: string) => void;
 }) {
   const [copied, setCopied] = useState(false);
 
@@ -111,7 +111,7 @@ function GitInfoMessage({
 
   const handleDiffClick = () => {
     if (commitHash && onOpenDiffViewer) {
-      onOpenDiffViewer(commitHash);
+      onOpenDiffViewer(commitHash, worktree || undefined);
     }
   };
 

ui/src/styles.css 🔗

@@ -3502,6 +3502,26 @@ svg {
   font-size: 0.875rem;
 }
 
+.diff-viewer-dir-btn {
+  width: 2rem;
+  height: 2rem;
+  border: none;
+  background: transparent;
+  color: var(--text-secondary);
+  cursor: pointer;
+  border-radius: 0.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  padding: 0;
+}
+
+.diff-viewer-dir-btn:hover {
+  background: var(--bg-tertiary);
+  color: var(--text-primary);
+}
+
 .diff-viewer-close {
   width: 2rem;
   height: 2rem;