From c641976a54a827fdb09f226ae35f7e9ab71a5dc0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 28 Sep 2025 20:40:05 +0000 Subject: [PATCH 1/5] feat: Add notes functionality for worktrees Adds API endpoints and UI for saving and retrieving notes per worktree. Notes are saved to a file named `.bob-notes-.md` and auto-saved. Co-authored-by: graham.mackie --- backend/src/routes/git.ts | 69 +++ frontend/src/api.ts | 19 + frontend/src/components/TerminalPanel.tsx | 495 +++++++++++++++------- 3 files changed, 440 insertions(+), 143 deletions(-) diff --git a/backend/src/routes/git.ts b/backend/src/routes/git.ts index dbda3d93..087c4e3a 100644 --- a/backend/src/routes/git.ts +++ b/backend/src/routes/git.ts @@ -961,4 +961,73 @@ Return only the complete file content with the requested improvements applied.`; } }); +// Get notes for a worktree +router.get('/:worktreeId/notes', async (req, res) => { + try { + const { worktreeId } = req.params; + + const gitService = req.app.locals.gitService; + const worktree = gitService.getWorktree(worktreeId); + + if (!worktree) { + return res.status(404).json({ error: 'Worktree not found' }); + } + + // Get current branch name + const { stdout: currentBranch } = await execAsync('git branch --show-current', { + cwd: worktree.path + }); + + const branchName = currentBranch.trim(); + const notesFileName = `.bob-notes-${branchName}.md`; + const notesFilePath = path.join(worktree.path, notesFileName); + + try { + const notesContent = await fs.promises.readFile(notesFilePath, 'utf8'); + res.json({ content: notesContent, fileName: notesFileName }); + } catch (error) { + // File doesn't exist, return empty content + res.json({ content: '', fileName: notesFileName }); + } + } catch (error) { + console.error('Error getting notes:', error); + res.status(500).json({ error: 'Failed to get notes' }); + } +}); + +// Save notes for a worktree +router.post('/:worktreeId/notes', async (req, res) => { + try { + const { worktreeId } = req.params; + const { content } = req.body; + + const gitService = req.app.locals.gitService; + const worktree = gitService.getWorktree(worktreeId); + + if (!worktree) { + return res.status(404).json({ error: 'Worktree not found' }); + } + + // Get current branch name + const { stdout: currentBranch } = await execAsync('git branch --show-current', { + cwd: worktree.path + }); + + const branchName = currentBranch.trim(); + const notesFileName = `.bob-notes-${branchName}.md`; + const notesFilePath = path.join(worktree.path, notesFileName); + + await fs.promises.writeFile(notesFilePath, content || '', 'utf8'); + + res.json({ + message: 'Notes saved successfully', + fileName: notesFileName, + path: notesFilePath + }); + } catch (error) { + console.error('Error saving notes:', error); + res.status(500).json({ error: 'Failed to save notes' }); + } +}); + export default router; \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bb278394..de03abfa 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -257,6 +257,25 @@ class ApiClient { }); } + // Notes operations + async getNotes(worktreeId: string): Promise<{ + content: string; + fileName: string; + }> { + return this.request(`/git/${worktreeId}/notes`); + } + + async saveNotes(worktreeId: string, content: string): Promise<{ + message: string; + fileName: string; + path: string; + }> { + return this.request(`/git/${worktreeId}/notes`, { + method: 'POST', + body: JSON.stringify({ content }), + }); + } + // System status and metrics async getSystemStatus(): Promise<{ claude: { diff --git a/frontend/src/components/TerminalPanel.tsx b/frontend/src/components/TerminalPanel.tsx index a599b7f7..bb9474a0 100644 --- a/frontend/src/components/TerminalPanel.tsx +++ b/frontend/src/components/TerminalPanel.tsx @@ -904,7 +904,7 @@ export const TerminalPanel: React.FC = ({ const [claudeTerminalSessionId, setClaudeTerminalSessionId] = useState(null); const [directoryTerminalSessionId, setDirectoryTerminalSessionId] = useState(null); - const [activeTab, setActiveTab] = useState<'claude' | 'directory' | 'git'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'directory' | 'git' | 'notes'>('claude'); const [isCreatingClaudeSession, setIsCreatingClaudeSession] = useState(false); const [isCreatingDirectorySession, setIsCreatingDirectorySession] = useState(false); const [isRestarting, setIsRestarting] = useState(false); @@ -931,6 +931,14 @@ export const TerminalPanel: React.FC = ({ const [currentAnalysisId, setCurrentAnalysisId] = useState(null); const [isApplyingFixes, setIsApplyingFixes] = useState(false); + // Notes state + const [notesContent, setNotesContent] = useState(''); + const [notesFileName, setNotesFileName] = useState(''); + const [isLoadingNotes, setIsLoadingNotes] = useState(false); + const [isSavingNotes, setIsSavingNotes] = useState(false); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const [autoSaveTimeout, setAutoSaveTimeout] = useState(null); + useEffect(() => { // Clear frontend terminal state when switching instances (but keep backend sessions alive) console.log(`Switching to instance: ${selectedInstance?.id}, clearing session state`); @@ -944,6 +952,14 @@ export const TerminalPanel: React.FC = ({ setAnalysisComplete(false); setAnalysisSummary(''); setCurrentAnalysisId(null); + // Clear notes state when switching + setNotesContent(''); + setNotesFileName(''); + setUnsavedChanges(false); + if (autoSaveTimeout) { + clearTimeout(autoSaveTimeout); + setAutoSaveTimeout(null); + } }, [selectedInstance?.id]); // Auto-connect to existing terminal sessions or create new ones when instance first becomes running @@ -1267,7 +1283,7 @@ export const TerminalPanel: React.FC = ({ if (deleteWorktreeOnDeny) { // Comprehensive cleanup: stop instance, revert changes, and delete worktree - // 1. Stop the Claude instance if running + // 1. Stop the Agent instance if running if (selectedInstance) { console.log('Stopping instance before worktree deletion...'); await onStopInstance(selectedInstance.id); @@ -1436,6 +1452,58 @@ export const TerminalPanel: React.FC = ({ } }; + // Notes operations + const loadNotes = async () => { + if (!selectedWorktree) return; + + setIsLoadingNotes(true); + try { + const notesData = await api.getNotes(selectedWorktree.id); + setNotesContent(notesData.content); + setNotesFileName(notesData.fileName); + setUnsavedChanges(false); + } catch (error) { + console.error('Failed to load notes:', error); + setNotesContent(''); + setNotesFileName(''); + } finally { + setIsLoadingNotes(false); + } + }; + + const saveNotes = async (content: string) => { + if (!selectedWorktree) return; + + setIsSavingNotes(true); + try { + const result = await api.saveNotes(selectedWorktree.id, content); + setNotesFileName(result.fileName); + setUnsavedChanges(false); + console.log('Notes saved:', result.message); + } catch (error) { + console.error('Failed to save notes:', error); + } finally { + setIsSavingNotes(false); + } + }; + + const handleNotesChange = (content: string) => { + setNotesContent(content); + setUnsavedChanges(true); + + // Clear existing timeout + if (autoSaveTimeout) { + clearTimeout(autoSaveTimeout); + } + + // Set new auto-save timeout (save after 2 seconds of no typing) + const timeout = setTimeout(() => { + saveNotes(content); + }, 2000); + + setAutoSaveTimeout(timeout); + }; + // Load git diff when switching to git tab useEffect(() => { @@ -1444,6 +1512,22 @@ export const TerminalPanel: React.FC = ({ } }, [activeTab, selectedWorktree?.id]); + // Load notes when switching to notes tab + useEffect(() => { + if (activeTab === 'notes' && selectedWorktree) { + loadNotes(); + } + }, [activeTab, selectedWorktree?.id]); + + // Cleanup autosave timeout on unmount + useEffect(() => { + return () => { + if (autoSaveTimeout) { + clearTimeout(autoSaveTimeout); + } + }; + }, [autoSaveTimeout]); + if (!selectedWorktree) { return (
@@ -1481,7 +1565,7 @@ export const TerminalPanel: React.FC = ({

- Claude Instance + Agent Instance = ({ className="button danger" style={{ fontSize: '12px', padding: '6px 12px' }} > - {isStopping ? 'Stopping...' : 'Stop Claude'} + {isStopping ? 'Stopping...' : 'Stop Agent'} )} @@ -1525,7 +1609,7 @@ export const TerminalPanel: React.FC = ({ className="button" style={{ fontSize: '12px', padding: '6px 12px' }} > - {isRestarting ? 'Restarting...' : 'Restart Claude'} + {isRestarting ? 'Restarting...' : 'Restart Agent'} )}

@@ -1564,7 +1648,7 @@ export const TerminalPanel: React.FC = ({ fontSize: '13px' }} > - Claude {claudeTerminalSessionId && '●'} + Agent {claudeTerminalSessionId && '●'} +
- {/* Claude Terminal */} + {/* Agent Terminal */}
= ({ }}> {claudeTerminalSessionId ? ( <> - {console.log(`Rendering Claude TerminalComponent with sessionId: ${claudeTerminalSessionId}`)} + {console.log(`Rendering Agent TerminalComponent with sessionId: ${claudeTerminalSessionId}`)} = ({ ) : selectedInstance.status === 'running' ? (
-

Claude Terminal

+

Agent Terminal

{isCreatingClaudeSession ? (
= ({ borderRadius: '50%', animation: 'spin 1s linear infinite' }}>
- Connecting to Claude... + Connecting to Agent...
) : ( <>

- Connect to the running Claude instance for AI assistance + Connect to the running Agent instance for AI assistance

)} @@ -1656,7 +1756,7 @@ export const TerminalPanel: React.FC = ({ ) : selectedInstance.status === 'starting' ? (
-

Claude Terminal

+

Agent Terminal

= ({ borderRadius: '50%', animation: 'spin 1s linear infinite' }}>
- Starting Claude instance... + Starting Agent instance...
) : (
-

Claude Terminal

+

Agent Terminal

- Claude instance must be running to connect + Agent instance must be running to connect

@@ -2055,147 +2155,256 @@ export const TerminalPanel: React.FC = ({
)} - {/* Denial Confirmation Modal */} - {showDenyConfirmation && ( +
+ + {/* Notes Tab */} +
+
+
+

Notes

+ {notesFileName && ( + + {notesFileName} + + )} + {unsavedChanges && ( + + ● Unsaved changes + + )} +
+
+ +
+
+ + {isLoadingNotes ? (
+ Loading notes... +
+ ) : ( +
+