From c502fbc57ac2fe0b009a09023d095f3782833f4d Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Wed, 10 Dec 2025 14:29:05 -0500 Subject: [PATCH] feat(backup): add backup.json for feature tracking and status updates - Introduced a new `backup.json` file to track feature statuses, descriptions, and summaries for better project management. - Updated `.automaker/feature_list.json` to reflect verified statuses for several features, ensuring accurate representation of progress. - Enhanced `memory.md` with details on drag-and-drop functionality for features in `waiting_approval` status. - Improved auto mode service to allow running tasks to complete when auto mode is stopped, enhancing user experience. --- .automaker/feature_list.json | 223 +++++++- .automaker/memory.md | 40 ++ app/electron/auto-mode-service.js | 23 +- app/electron/preload.js | 4 +- app/electron/services/context-manager.js | 195 +++++++ app/electron/services/feature-executor.js | 59 +- app/src/app/globals.css | 525 ++++++++++++++++++ app/src/components/layout/sidebar.tsx | 178 ++++-- app/src/components/session-manager.tsx | 10 +- .../components/ui/category-autocomplete.tsx | 106 ++-- .../ui/description-image-dropzone.tsx | 3 + app/src/components/ui/hotkey-button.tsx | 296 ++++++++++ app/src/components/ui/image-drop-zone.tsx | 30 +- app/src/components/ui/input.tsx | 4 +- app/src/components/ui/xml-syntax-editor.tsx | 290 ++++++++++ app/src/components/views/agent-view.tsx | 18 +- app/src/components/views/board-view.tsx | 250 +++++++-- app/src/components/views/context-view.tsx | 19 +- .../views/feature-suggestions-dialog.tsx | 7 +- app/src/components/views/kanban-card.tsx | 26 +- app/src/components/views/profiles-view.tsx | 26 +- app/src/components/views/settings-view.tsx | 156 ++++-- app/src/components/views/spec-view.tsx | 20 +- app/src/components/views/welcome-view.tsx | 7 +- app/src/hooks/use-auto-mode.ts | 11 +- backup.json | 269 +++++++++ 26 files changed, 2497 insertions(+), 298 deletions(-) create mode 100644 app/src/components/ui/hotkey-button.tsx create mode 100644 app/src/components/ui/xml-syntax-editor.tsx create mode 100644 backup.json diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index a797057a..772a0f93 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -4,7 +4,7 @@ "category": "Kanban", "description": "In the output logs of the proc agent output in the file diffs Can you add a scroll bar so it actually scroll to see all these new styles right now it seems like I can't scroll", "steps": [], - "status": "waiting_approval", + "status": "verified", "startedAt": "2025-12-10T17:42:09.158Z", "imagePaths": [], "skipTests": true, @@ -68,7 +68,7 @@ "category": "Uncategorized", "description": "Can you please add some spacing and fix the styling of the hotkey with the command enter and make it so they're both vertically aligned for those icons?", "steps": [], - "status": "waiting_approval", + "status": "verified", "startedAt": "2025-12-10T17:44:08.667Z", "imagePaths": [ { @@ -87,7 +87,7 @@ "category": "Uncategorized", "description": "Fix the styling on all the buttons when I hover over them with my mouse they never change to a click mouse cursor. In order they seem to show any type of like hover state changes, if they do, at least for the certain game I'm using, it's not very obvious that you're hovering over the button.", "steps": [], - "status": "waiting_approval", + "status": "verified", "startedAt": "2025-12-10T17:45:59.666Z", "imagePaths": [], "skipTests": true, @@ -100,7 +100,7 @@ "category": "Kanban", "description": "The tabs in the add new feature modal for the prompt model and testing tabs. They don't seem to look like tabs when I'm on a certain theme. Can you verify that those are hooked into the theme? And make sure that the active one is colored differently than the unactive ones. Keep the primary colors when doing this.", "steps": [], - "status": "waiting_approval", + "status": "verified", "startedAt": "2025-12-10T17:46:00.019Z", "imagePaths": [], "skipTests": true, @@ -113,7 +113,7 @@ "category": "Uncategorized", "description": "There's a strange issue when I when when these agents are like doing things it seems like it completely refreshes the whole Kanban board and there's like a black flash. Can you verify that the data loading does not cause the entire component to refresh? Maybe there's an issue with the react effect or how the component is rendered maybe we need some used memos or something but it shouldn't refresh the whole page it should just like update the individual cards when they change.", "steps": [], - "status": "waiting_approval", + "status": "verified", "startedAt": "2025-12-10T17:47:20.170Z", "imagePaths": [], "skipTests": true, @@ -126,11 +126,11 @@ "category": "Uncategorized", "description": "Add in the ability so that every project can have its own selected theme. This will allow me to have different projects have different themes so I can easily differentiate when I have one project selected or not.", "steps": [], - "status": "waiting_approval", - "startedAt": "2025-12-10T17:54:11.363Z", + "status": "verified", + "startedAt": "2025-12-10T18:00:33.814Z", "imagePaths": [], "skipTests": true, - "summary": "Added per-project theme support. Modified: electron.ts (added theme property to Project interface), app-store.ts (added setProjectTheme and getEffectiveTheme actions), page.tsx (uses effectiveTheme for theme switching), sidebar.tsx (added project theme selector dropdown with all 13 themes + \"Use Global\" option). Users can now set unique themes for each project via the project options menu in the sidebar.", + "summary": "Fixed per-project theme support. Modified: settings-view.tsx (now saves theme to project when project is selected, shows label indicating scope), page.tsx (computes effectiveTheme from currentProject?.theme || theme), app-store.ts (added setProjectTheme action, theme property on Project interface). When a project is selected, changing theme in Settings saves to that project only.", "model": "opus", "thinkingLevel": "none" }, @@ -139,7 +139,8 @@ "category": "Agent Runner", "description": "On the Agent Runner, I took a screenshot and dropped it into the text area and after a certain amount of time, it's like the image preview just completely went away. Can you debug and fix this on the Agent Runner?", "steps": [], - "status": "backlog", + "status": "verified", + "startedAt": "2025-12-10T18:11:17.561Z", "imagePaths": [], "skipTests": true, "model": "opus", @@ -150,9 +151,211 @@ "category": "Kanban", "description": "It seems like the category typehead is no longer working. Can you double check that code didn't break? It should have kept track of categories inside of the categories.json file inside the .automaker folder when adding new features modal", "steps": [], - "status": "backlog", + "status": "verified", + "startedAt": "2025-12-10T18:17:22.274Z", "imagePaths": [], "skipTests": true, + "summary": "Fixed category typeahead dropdown being clipped by overflow containers. Modified: category-autocomplete.tsx. Changed dropdown to use React Portal (createPortal) to render to document.body with fixed positioning and z-index 9999. Added scroll/resize position tracking to keep dropdown aligned with input.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765389420151-jzdsjzn9u", + "category": "Kanban", + "description": "Add in the ability to just click and drag a card from the waiting approval directly into the verify column as I can usually just commit it manually if I want to.", + "steps": [], + "status": "verified", + "startedAt": "2025-12-10T18:05:08.252Z", + "imagePaths": [], + "skipTests": true, + "summary": "Fixed drag-and-drop from waiting_approval to verified column. The issue was condition ordering in handleDragEnd - the skipTests check was intercepting waiting_approval features before they could be handled. Moved waiting_approval status check before skipTests check in board-view.tsx:731-752. Also updated agent memory with this lesson.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765389468077-9x3vt1yjq", + "category": "Uncategorized", + "description": "The commit functionality on the waiting approval cards doesn't seem to work. It just committed everything in my working copy for git. I think I should be a little bit more intelligent and figure out what files it changed for that AI session and then only try to git add those individual files and commit those. Right now it just basically did a git add all and committed those. Re-factor the prompting or figure out a way to make it so it's more specific on what it's going to commit with the future change.", + "steps": [], + "status": "verified", + "startedAt": "2025-12-10T18:17:22.580Z", + "imagePaths": [], + "skipTests": true, + "summary": "Fixed commit functionality to only commit files changed during the AI session, not all working directory changes. Added git state tracking in context-manager.js (saveInitialGitState, getFilesChangedDuringSession methods) and updated commit prompt in feature-executor.js to use specific file lists instead of 'git add .'", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765389502705-6deep7mvi", + "category": "Uncategorized", + "description": "I'm noticing that a lot of buttons in the UI, especially the ones that are submitting, are either missing the submit hotkey or they're not styled properly. Look at the add feature submit button that's on the add feature modal and abstract away a submit button so that on every single page that needs to submit something I can reuse this type of hotkey functionality. In fact, every single button should be abstracted enough where I can provide a hotkey and it will automatically listen if I press that hotkey when it's in view.", + "steps": [], + "status": "waiting_approval", + "startedAt": "2025-12-10T19:03:41.338Z", + "imagePaths": [], + "skipTests": true, + "summary": "Fixed duplicate hotkey listener issue. When HotkeyButton was used with simple keys (N, F, G) that were already handled by useKeyboardShortcuts, it created duplicate listeners. Added hotkeyActive={false} to HotkeyButton instances in board-view.tsx (Add Feature, Start Next), context-view.tsx (Add File), profiles-view.tsx (New Profile), and session-manager.tsx (New) where useKeyboardShortcuts already handles the hotkey. Also updated memory.md with this lesson learned.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765389772166-an3yk3kpo", + "category": "Uncategorized", + "description": "Can you add some more padding to the bottom of the settings panel? Notice that I can't scroll down all the way. And that doesn't highlight the left sub navigation to highlight it pink when I'm on that section. I should be able to scroll a bit further and just have like blank space at the bottom. So I can eventually get to that actual section.", + "steps": [], + "status": "verified", + "imagePaths": [ + { + "id": "img-1765389750685-jhq6rcidc", + "path": "/Users/webdevcody/Library/Application Support/automaker/images/1765389750683-mqb0j7a3z_Screenshot_2025-12-10_at_1.02.26_PM.png", + "filename": "Screenshot 2025-12-10 at 1.02.26 PM.png", + "mimeType": "image/png" + } + ], + "skipTests": true, + "summary": "Added bottom padding (pb-96) to settings panel content area to allow scrolling past last section. Improved scroll detection to highlight the last navigation item when scrolled to bottom. Modified: settings-view.tsx", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765389829239-bbk596u6z", + "category": "Uncategorized", + "description": "Add some type of XML highlighting to the spec editor view. Right now it's just all grayscale and it's kind of ugly to look at. And try to make the syntax highlighting match the current selected theme.", + "steps": [], + "status": "verified", + "imagePaths": [], + "skipTests": true, + "summary": "Added XML syntax highlighting to spec editor view. Created: xml-syntax-editor.tsx component with custom XML tokenizer and theme-aware syntax highlighting. Modified: spec-view.tsx to use new editor, globals.css with 500+ lines of theme-specific syntax highlighting colors for all 12 themes (light, dark, retro, dracula, nord, monokai, tokyonight, solarized, gruvbox, catppuccin, onedark, synthwave). Features: highlights tag brackets, tag names, attribute names, attribute values, comments, CDATA, DOCTYPE. Tab key indentation supported.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765389859334-si9ivtehw", + "category": "Uncategorized", + "description": "Add a search bar to the top of the Kanban column that allows me to search the filter down just to show the cards I'm interested in by keyword.", + "steps": [], + "status": "verified", + "startedAt": "2025-12-10T18:09:26.193Z", + "imagePaths": [], + "skipTests": true, + "summary": "Added forward slash (/) keyboard shortcut to focus search input. Modified: board-view.tsx - added searchInputRef, registered '/' shortcut in boardShortcuts, updated placeholder to show hint '(Press / to focus)'", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765390022638-nalulsdxv", + "category": "Uncategorized", + "description": "In the project select can you actually remove the whole like 1 2 3 4 5 hotkeys instead? Just make it be a type ahead so when I open the panel I just should be able to type in the first letter or two of the project that I want and press enter and that should Just select it for me", + "steps": [], + "status": "verified", + "imagePaths": [], + "skipTests": true, + "summary": "Replaced hotkey-based project selection (1-9) with type-ahead search. Modified: sidebar.tsx. Added search input with filtering, arrow key navigation (↑↓), Enter to select, and visual highlighting. Auto-focuses search when dropdown opens.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765390055621-ewc4w7k5h", + "category": "Uncategorized", + "description": "In the add new feature prompt, instead of disabling the add feature button until we type into the description, keep it enabled. But if you click it, make sure you just show the client side validation and turn the description box in any other required field as red so that the user knows they have to fill it in.", + "steps": [], + "status": "verified", + "imagePaths": [], + "skipTests": true, + "summary": "Added client-side validation for the Add Feature dialog. The Add Feature button is now always enabled. When clicked without a description, it shows a red border around the description field using aria-invalid styling. Modified: board-view.tsx (added descriptionError state, validation in handleAddFeature, error prop passing), description-image-dropzone.tsx (added error prop that sets aria-invalid on textarea).", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765390131625-ymqxr5gln", + "category": "Uncategorized", + "description": "Can you please in the top right of the Kanban board use the show three icons for the Kanban card display formatting. You can look at the settings page to see that there's three different settings that we use for displaying the Kanban card information. But I also just want this to be really quickly accessible at the top right of the Kanban that they can switch between those three toggles. Keep them simple only just icons you don't need to put words in them. Make sure they do have harbor states though, or tooltips I mean.", + "steps": [], + "status": "waiting_approval", + "startedAt": "2025-12-10T18:58:21.431Z", + "imagePaths": [], + "skipTests": true, + "summary": "Moved Kanban card detail toggle icons to search bar row. Modified: board-view.tsx. Three icons (Minimize2, Square, Maximize2) now appear on the right side of the search bar with tooltips and hover states.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765390359456-n0vvdurjb", + "category": "Kanban", + "description": "When the item is in the backlog, do not show the logs. There's no reason for a user to look at the logs if it's in the backlog. So remove the logs button from the card, the Kanban card, if it's in the backlog.", + "steps": [], + "status": "verified", + "imagePaths": [], + "skipTests": true, + "summary": "Removed logs button from Kanban cards when feature is in backlog. Modified: kanban-card.tsx - removed the dedicated logs button section for backlog items (lines 743-761) and added condition to hide logs option in dropdown menu for backlog items.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765390428237-4ekiscpsf", + "category": "Uncategorized", + "description": "On the Kanban search bar, instead of press slash to focus, just use the normal shortcut display button that we've been using everywhere else in the application. Can you keep it consistent, please?", + "steps": [], + "status": "verified", + "imagePaths": [ + { + "id": "img-1765390414226-66vm6cly4", + "path": "/Users/webdevcody/Library/Application Support/automaker/images/1765390414225-7o9wizw90_Screenshot_2025-12-10_at_1.13.32_PM.png", + "filename": "Screenshot 2025-12-10 at 1.13.32 PM.png", + "mimeType": "image/png" + } + ], + "skipTests": true, + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765390699789-uaxtse6hn", + "category": "Uncategorized", + "description": "Please fix the styling of this on the Agent Runner, make it match the theme of the project.", + "steps": [], + "status": "verified", + "imagePaths": [ + { + "id": "img-1765390692809-0hahbe30j", + "path": "/Users/webdevcody/Library/Application Support/automaker/images/1765390692808-u8dgwxx9n_Screenshot_2025-12-10_at_1.18.07_PM.png", + "filename": "Screenshot 2025-12-10 at 1.18.07 PM.png", + "mimeType": "image/png" + } + ], + "skipTests": true, + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765393057026-cjgr70d97", + "category": "Kanban", + "description": "there is a major bug: stopping auto mode should not cancel all running tasks, it should just turn off the auto toggle.", + "steps": [], + "status": "waiting_approval", + "startedAt": "2025-12-10T18:57:56.137Z", + "imagePaths": [], + "skipTests": true, + "summary": "Fixed auto mode stop to only turn off the toggle, not cancel running tasks. Modified: auto-mode-service.js (removed abort/clear logic from stop()), use-auto-mode.ts (removed clearRunningTasks from stop callback). Running features now complete naturally.", + "model": "opus", + "thinkingLevel": "none" + }, + { + "id": "feature-1765393405243-xe047s4h5", + "category": "Uncategorized", + "description": "fix the style of the input on the kanban route to add a border around the entire input", + "steps": [], + "status": "waiting_approval", + "startedAt": "2025-12-10T19:08:38.024Z", + "imagePaths": [ + { + "id": "img-1765393386453-nd1qucdne", + "path": "/Users/webdevcody/Workspace/automaker/.automaker/images/1765393386452-bz4q5pbkw_Screenshot_2025-12-10_at_2.03.04_PM.png", + "filename": "Screenshot 2025-12-10 at 2.03.04 PM.png", + "mimeType": "image/png" + } + ], + "skipTests": true, "model": "opus", "thinkingLevel": "none" } diff --git a/.automaker/memory.md b/.automaker/memory.md index e86144e4..f9a80351 100644 --- a/.automaker/memory.md +++ b/.automaker/memory.md @@ -84,6 +84,22 @@ Note: `currentView` is NOT persisted - it's managed through actions. 4. `auto_mode_feature_complete` event fires → feature removed from `runningAutoTasks` 5. If `passes: true` → status becomes "verified", if `passes: false` → stays "in_progress" +### Issue: waiting_approval features not draggable when skipTests=true +**Problem:** Features in `waiting_approval` status couldn't be dragged to `verified` column, even though the code appeared to handle it. +**Fix:** The order of condition checks in `handleDragEnd` matters. The `skipTests` check was catching `waiting_approval` features before the `waiting_approval` status check could handle them. Move the `waiting_approval` status check **before** the `skipTests` check in `board-view.tsx`: + +```typescript +// Correct order in handleDragEnd: +if (draggedFeature.status === "backlog") { + // ... +} else if (draggedFeature.status === "waiting_approval") { + // Handle waiting_approval BEFORE skipTests check + // because waiting_approval features often have skipTests=true +} else if (draggedFeature.skipTests) { + // Handle other skipTests features +} +``` + ## Best Practices Discovered ### Testing utilities are critical @@ -107,3 +123,27 @@ The mock auto mode in `electron.ts` has predictable timing: - Total duration: ~2.4 seconds (300+500+300+300+500+500ms) - Plus 1.5s delay before auto-closing modals - Total: ~4 seconds from start to completion + +### Issue: HotkeyButton conflicting with useKeyboardShortcuts +**Problem:** Adding `HotkeyButton` with a simple key (like "N") to buttons that already had keyboard shortcuts registered via `useKeyboardShortcuts` caused the hotkey to stop working. Both registered duplicate listeners, and the HotkeyButton's `stopPropagation()` call could interfere. +**Fix:** When a simple single-key hotkey is already handled by `useKeyboardShortcuts`, set `hotkeyActive={false}` on the `HotkeyButton` so it only displays the indicator badge without registering a duplicate listener: + +```tsx +// In views that already use useKeyboardShortcuts for the "N" key: + setShowAddDialog(true)} + hotkey={ACTION_SHORTCUTS.addFeature} + hotkeyActive={false} // <-- Important! Prevents duplicate listener +> + Add Feature + + +// HotkeyButton should only actively listen when it's the sole handler (e.g., Cmd+Enter in dialogs) + + Submit + +``` diff --git a/app/electron/auto-mode-service.js b/app/electron/auto-mode-service.js index 054c3b03..2407dedb 100644 --- a/app/electron/auto-mode-service.js +++ b/app/electron/auto-mode-service.js @@ -128,10 +128,12 @@ class AutoModeService { } /** - * Stop auto mode - stops the auto loop and all running features + * Stop auto mode - stops the auto loop but lets running features complete + * This only turns off the auto toggle to prevent picking up new features. + * Running tasks will continue until they complete naturally. */ async stop() { - console.log("[AutoMode] Stopping auto mode"); + console.log("[AutoMode] Stopping auto mode (letting running features complete)"); this.autoLoopRunning = false; @@ -147,18 +149,15 @@ class AutoModeService { this.autoLoopAbortController = null; } - // Abort all running features - for (const [featureId, execution] of this.runningFeatures.entries()) { - console.log(`[AutoMode] Aborting feature: ${featureId}`); - if (execution.abortController) { - execution.abortController.abort(); - } - } + // NOTE: We intentionally do NOT abort running features here. + // Stopping auto mode should only turn off the toggle to prevent new features + // from being picked up. Running features will complete naturally. + // Use stopFeature() to cancel a specific running feature if needed. - // Clear all running features - this.runningFeatures.clear(); + const runningCount = this.runningFeatures.size; + console.log(`[AutoMode] Auto loop stopped. ${runningCount} feature(s) still running and will complete.`); - return { success: true }; + return { success: true, runningFeatures: runningCount }; } /** diff --git a/app/electron/preload.js b/app/electron/preload.js index 65d2b03a..c0fe2c7b 100644 --- a/app/electron/preload.js +++ b/app/electron/preload.js @@ -23,8 +23,8 @@ contextBridge.exposeInMainWorld("electronAPI", { // App APIs getPath: (name) => ipcRenderer.invoke("app:getPath", name), - saveImageToTemp: (data, filename, mimeType) => - ipcRenderer.invoke("app:saveImageToTemp", { data, filename, mimeType }), + saveImageToTemp: (data, filename, mimeType, projectPath) => + ipcRenderer.invoke("app:saveImageToTemp", { data, filename, mimeType, projectPath }), // Agent APIs agent: { diff --git a/app/electron/services/context-manager.js b/app/electron/services/context-manager.js index 22e4a609..607c806b 100644 --- a/app/electron/services/context-manager.js +++ b/app/electron/services/context-manager.js @@ -200,6 +200,201 @@ This helps future agent runs avoid the same pitfalls. return ""; } } + + /** + * Save the initial git state before a feature starts executing + * This captures all files that were already modified before the AI agent started + * @param {string} projectPath - Path to the project + * @param {string} featureId - Feature ID + * @returns {Promise<{modifiedFiles: string[], untrackedFiles: string[]}>} + */ + async saveInitialGitState(projectPath, featureId) { + if (!projectPath) return { modifiedFiles: [], untrackedFiles: [] }; + + try { + const { execSync } = require("child_process"); + const contextDir = path.join(projectPath, ".automaker", "agents-context"); + + // Ensure directory exists + try { + await fs.access(contextDir); + } catch { + await fs.mkdir(contextDir, { recursive: true }); + } + + // Get list of modified files (both staged and unstaged) + let modifiedFiles = []; + try { + const modifiedOutput = execSync("git diff --name-only HEAD", { + cwd: projectPath, + encoding: "utf-8", + }).trim(); + if (modifiedOutput) { + modifiedFiles = modifiedOutput.split("\n").filter(Boolean); + } + } catch (error) { + console.log("[ContextManager] No modified files or git error:", error.message); + } + + // Get list of untracked files + let untrackedFiles = []; + try { + const untrackedOutput = execSync("git ls-files --others --exclude-standard", { + cwd: projectPath, + encoding: "utf-8", + }).trim(); + if (untrackedOutput) { + untrackedFiles = untrackedOutput.split("\n").filter(Boolean); + } + } catch (error) { + console.log("[ContextManager] Error getting untracked files:", error.message); + } + + // Save the initial state to a JSON file + const stateFile = path.join(contextDir, `${featureId}-git-state.json`); + const state = { + timestamp: new Date().toISOString(), + modifiedFiles, + untrackedFiles, + }; + + await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf-8"); + console.log(`[ContextManager] Saved initial git state for ${featureId}:`, { + modifiedCount: modifiedFiles.length, + untrackedCount: untrackedFiles.length, + }); + + return state; + } catch (error) { + console.error("[ContextManager] Failed to save initial git state:", error); + return { modifiedFiles: [], untrackedFiles: [] }; + } + } + + /** + * Get the initial git state saved before a feature started executing + * @param {string} projectPath - Path to the project + * @param {string} featureId - Feature ID + * @returns {Promise<{modifiedFiles: string[], untrackedFiles: string[], timestamp: string} | null>} + */ + async getInitialGitState(projectPath, featureId) { + if (!projectPath) return null; + + try { + const stateFile = path.join( + projectPath, + ".automaker", + "agents-context", + `${featureId}-git-state.json` + ); + const content = await fs.readFile(stateFile, "utf-8"); + return JSON.parse(content); + } catch (error) { + console.log(`[ContextManager] No initial git state found for ${featureId}`); + return null; + } + } + + /** + * Delete the git state file for a feature + * @param {string} projectPath - Path to the project + * @param {string} featureId - Feature ID + */ + async deleteGitStateFile(projectPath, featureId) { + if (!projectPath) return; + + try { + const stateFile = path.join( + projectPath, + ".automaker", + "agents-context", + `${featureId}-git-state.json` + ); + await fs.unlink(stateFile); + console.log(`[ContextManager] Deleted git state file for ${featureId}`); + } catch (error) { + // File might not exist, which is fine + if (error.code !== "ENOENT") { + console.error("[ContextManager] Failed to delete git state file:", error); + } + } + } + + /** + * Calculate which files were changed during the AI session + * by comparing current git state with the saved initial state + * @param {string} projectPath - Path to the project + * @param {string} featureId - Feature ID + * @returns {Promise<{newFiles: string[], modifiedFiles: string[]}>} + */ + async getFilesChangedDuringSession(projectPath, featureId) { + if (!projectPath) return { newFiles: [], modifiedFiles: [] }; + + try { + const { execSync } = require("child_process"); + + // Get initial state + const initialState = await this.getInitialGitState(projectPath, featureId); + + // Get current state + let currentModified = []; + try { + const modifiedOutput = execSync("git diff --name-only HEAD", { + cwd: projectPath, + encoding: "utf-8", + }).trim(); + if (modifiedOutput) { + currentModified = modifiedOutput.split("\n").filter(Boolean); + } + } catch (error) { + console.log("[ContextManager] No modified files or git error"); + } + + let currentUntracked = []; + try { + const untrackedOutput = execSync("git ls-files --others --exclude-standard", { + cwd: projectPath, + encoding: "utf-8", + }).trim(); + if (untrackedOutput) { + currentUntracked = untrackedOutput.split("\n").filter(Boolean); + } + } catch (error) { + console.log("[ContextManager] Error getting untracked files"); + } + + if (!initialState) { + // No initial state - all current changes are considered from this session + console.log("[ContextManager] No initial state found, returning all current changes"); + return { + newFiles: currentUntracked, + modifiedFiles: currentModified, + }; + } + + // Calculate files that are new since the session started + const initialModifiedSet = new Set(initialState.modifiedFiles || []); + const initialUntrackedSet = new Set(initialState.untrackedFiles || []); + + // New files = current untracked - initial untracked + const newFiles = currentUntracked.filter(f => !initialUntrackedSet.has(f)); + + // Modified files = current modified - initial modified + const modifiedFiles = currentModified.filter(f => !initialModifiedSet.has(f)); + + console.log(`[ContextManager] Files changed during session for ${featureId}:`, { + newFilesCount: newFiles.length, + modifiedFilesCount: modifiedFiles.length, + newFiles, + modifiedFiles, + }); + + return { newFiles, modifiedFiles }; + } catch (error) { + console.error("[ContextManager] Failed to calculate changed files:", error); + return { newFiles: [], modifiedFiles: [] }; + } + } } module.exports = new ContextManager(); diff --git a/app/electron/services/feature-executor.js b/app/electron/services/feature-executor.js index 8ab6e14e..c25ae1c5 100644 --- a/app/electron/services/feature-executor.js +++ b/app/electron/services/feature-executor.js @@ -193,6 +193,10 @@ class FeatureExecutor { let isCodex; try { + // Save the initial git state before starting implementation + // This allows us to track only files changed during this session when committing + await contextManager.saveInitialGitState(projectPath, feature.id); + // ======================================== // PHASE 1: PLANNING // ======================================== @@ -1062,6 +1066,23 @@ class FeatureExecutor { content: "Analyzing changes and creating commit...", }); + // Get the files that were changed during this AI session + const changedFiles = await contextManager.getFilesChangedDuringSession( + projectPath, + feature.id + ); + + // Combine new files and modified files into a single list of files to commit + const sessionFiles = [ + ...changedFiles.newFiles, + ...changedFiles.modifiedFiles, + ]; + + console.log( + `[FeatureExecutor] Files changed during session: ${sessionFiles.length}`, + sessionFiles + ); + const abortController = new AbortController(); execution.abortController = abortController; @@ -1080,7 +1101,9 @@ IMPORTANT RULES: - DO NOT write tests - DO NOT do anything except analyzing changes and committing them - Use the git command line tools via Bash -- Create proper conventional commit messages based on what was actually changed`, +- Create proper conventional commit messages based on what was actually changed +- ONLY commit the specific files that were changed during the AI session (provided in the prompt) +- DO NOT use 'git add .' - only add the specific files listed`, maxTurns: 15, // Allow some turns to analyze and commit cwd: projectPath, mcpServers: { @@ -1094,25 +1117,44 @@ IMPORTANT RULES: abortController: abortController, }; + // Build the file list section for the prompt + let fileListSection = ""; + if (sessionFiles.length > 0) { + fileListSection = ` +**Files Changed During This AI Session:** +The following files were modified or created during this feature implementation: +${sessionFiles.map((f) => `- ${f}`).join("\n")} + +**CRITICAL:** Only commit these specific files listed above. Do NOT use \`git add .\` or \`git add -A\`. +Instead, add each file individually or use: \`git add ${sessionFiles.map((f) => `"${f}"`).join(" ")}\` +`; + } else { + fileListSection = ` +**Note:** No specific files were tracked for this session. Please run \`git status\` to see what files have been modified, and only stage files that appear to be related to this feature implementation. Be conservative - if a file doesn't seem related to this feature, don't include it. +`; + } + // Prompt that guides the agent to create a proper conventional commit - const prompt = `Please commit the current changes with a proper conventional commit message. + const prompt = `Please commit the changes for this feature with a proper conventional commit message. **Feature Context:** Category: ${feature.category} Description: ${feature.description} - +${fileListSection} **Your Task:** -1. First, run \`git status\` to see all untracked and modified files -2. Run \`git diff\` to see the actual changes (both staged and unstaged) +1. First, run \`git status\` to see the current state of the repository +2. Run \`git diff\` on the specific files listed above to see the actual changes 3. Run \`git log --oneline -5\` to see recent commit message styles in this repo -4. Analyze all the changes and draft a proper conventional commit message: +4. Analyze the changes in the files and draft a proper conventional commit message: - Use conventional commit format: \`type(scope): description\` - Types: feat, fix, refactor, style, docs, test, chore - The description should be concise (under 72 chars) and focus on "what" was done - Summarize the nature of the changes (new feature, enhancement, bug fix, etc.) - Make sure the commit message accurately reflects the actual code changes -5. Run \`git add .\` to stage all changes +5. Stage ONLY the specific files that were changed during this session (listed above) + - DO NOT use \`git add .\` or \`git add -A\` + - Add files individually: \`git add "path/to/file1" "path/to/file2"\` 6. Create the commit with a message ending with: 🤖 Generated with [Claude Code](https://claude.com/claude-code) @@ -1136,7 +1178,8 @@ EOF - DO NOT use the feature description verbatim as the commit message - Analyze the actual code changes to determine the appropriate commit message - The commit message should be professional and follow conventional commit standards -- DO NOT modify any code or run tests - ONLY commit the existing changes`; +- DO NOT modify any code or run tests - ONLY commit the existing changes +- ONLY stage the specific files listed above - do not commit unrelated changes`; const currentQuery = query({ prompt, options }); execution.query = currentQuery; diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 50379b13..36444bdb 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1727,3 +1727,528 @@ .titlebar-no-drag { -webkit-app-region: no-drag; } + +/* ======================================== + XML SYNTAX HIGHLIGHTING + Theme-aware colors for XML editor + ======================================== */ + +/* Light theme - professional and readable */ +.light .xml-highlight { + color: oklch(0.3 0 0); /* Default text */ +} + +.light .xml-tag-bracket { + color: oklch(0.45 0.15 250); /* Blue-gray for < > */ +} + +.light .xml-tag-name { + color: oklch(0.45 0.22 25); /* Red/maroon for tag names */ +} + +.light .xml-attribute-name { + color: oklch(0.45 0.18 280); /* Purple for attributes */ +} + +.light .xml-attribute-equals { + color: oklch(0.4 0 0); /* Dark gray for = */ +} + +.light .xml-attribute-value { + color: oklch(0.45 0.18 145); /* Green for string values */ +} + +.light .xml-comment { + color: oklch(0.55 0.05 100); /* Muted olive for comments */ + font-style: italic; +} + +.light .xml-cdata { + color: oklch(0.5 0.1 200); /* Teal for CDATA */ +} + +.light .xml-doctype { + color: oklch(0.5 0.15 280); /* Purple for DOCTYPE */ +} + +.light .xml-text { + color: oklch(0.25 0 0); /* Near-black for text content */ +} + +/* Dark theme - high contrast */ +.dark .xml-highlight { + color: oklch(0.9 0 0); /* Default light text */ +} + +.dark .xml-tag-bracket { + color: oklch(0.7 0.12 220); /* Soft blue for < > */ +} + +.dark .xml-tag-name { + color: oklch(0.75 0.2 25); /* Coral/salmon for tag names */ +} + +.dark .xml-attribute-name { + color: oklch(0.8 0.15 280); /* Light purple for attributes */ +} + +.dark .xml-attribute-equals { + color: oklch(0.6 0 0); /* Gray for = */ +} + +.dark .xml-attribute-value { + color: oklch(0.8 0.18 145); /* Bright green for strings */ +} + +.dark .xml-comment { + color: oklch(0.55 0.05 100); /* Muted for comments */ + font-style: italic; +} + +.dark .xml-cdata { + color: oklch(0.7 0.12 200); /* Teal for CDATA */ +} + +.dark .xml-doctype { + color: oklch(0.7 0.15 280); /* Purple for DOCTYPE */ +} + +.dark .xml-text { + color: oklch(0.85 0 0); /* Off-white for text */ +} + +/* Retro theme - neon green on black */ +.retro .xml-highlight { + color: oklch(0.85 0.25 145); /* Neon green default */ +} + +.retro .xml-tag-bracket { + color: oklch(0.8 0.25 200); /* Cyan for brackets */ +} + +.retro .xml-tag-name { + color: oklch(0.85 0.25 145); /* Bright green for tags */ + text-shadow: 0 0 5px oklch(0.85 0.25 145 / 0.5); +} + +.retro .xml-attribute-name { + color: oklch(0.8 0.25 300); /* Purple neon for attrs */ +} + +.retro .xml-attribute-equals { + color: oklch(0.6 0.15 145); /* Dim green for = */ +} + +.retro .xml-attribute-value { + color: oklch(0.8 0.25 60); /* Yellow neon for strings */ +} + +.retro .xml-comment { + color: oklch(0.5 0.15 145); /* Dim green for comments */ + font-style: italic; +} + +.retro .xml-cdata { + color: oklch(0.75 0.2 200); /* Cyan for CDATA */ +} + +.retro .xml-doctype { + color: oklch(0.75 0.2 300); /* Purple for DOCTYPE */ +} + +.retro .xml-text { + color: oklch(0.7 0.2 145); /* Green text */ +} + +/* Dracula theme */ +.dracula .xml-highlight { + color: oklch(0.95 0.01 280); /* #f8f8f2 */ +} + +.dracula .xml-tag-bracket { + color: oklch(0.7 0.25 350); /* Pink #ff79c6 */ +} + +.dracula .xml-tag-name { + color: oklch(0.7 0.25 350); /* Pink for tags */ +} + +.dracula .xml-attribute-name { + color: oklch(0.8 0.2 130); /* Green #50fa7b */ +} + +.dracula .xml-attribute-equals { + color: oklch(0.95 0.01 280); /* White */ +} + +.dracula .xml-attribute-value { + color: oklch(0.85 0.2 90); /* Yellow #f1fa8c */ +} + +.dracula .xml-comment { + color: oklch(0.55 0.08 280); /* #6272a4 */ + font-style: italic; +} + +.dracula .xml-cdata { + color: oklch(0.75 0.2 180); /* Cyan */ +} + +.dracula .xml-doctype { + color: oklch(0.7 0.2 320); /* Purple #bd93f9 */ +} + +.dracula .xml-text { + color: oklch(0.95 0.01 280); /* White */ +} + +/* Nord theme */ +.nord .xml-highlight { + color: oklch(0.9 0.01 230); /* #eceff4 */ +} + +.nord .xml-tag-bracket { + color: oklch(0.65 0.14 220); /* #81a1c1 */ +} + +.nord .xml-tag-name { + color: oklch(0.65 0.14 220); /* Frost blue for tags */ +} + +.nord .xml-attribute-name { + color: oklch(0.7 0.12 220); /* #88c0d0 */ +} + +.nord .xml-attribute-equals { + color: oklch(0.75 0.02 230); /* Dim white */ +} + +.nord .xml-attribute-value { + color: oklch(0.7 0.15 140); /* #a3be8c green */ +} + +.nord .xml-comment { + color: oklch(0.5 0.04 230); /* Dim text */ + font-style: italic; +} + +.nord .xml-cdata { + color: oklch(0.7 0.12 220); /* Frost blue */ +} + +.nord .xml-doctype { + color: oklch(0.7 0.2 320); /* #b48ead purple */ +} + +.nord .xml-text { + color: oklch(0.9 0.01 230); /* Snow white */ +} + +/* Monokai theme */ +.monokai .xml-highlight { + color: oklch(0.95 0.02 100); /* #f8f8f2 */ +} + +.monokai .xml-tag-bracket { + color: oklch(0.95 0.02 100); /* White */ +} + +.monokai .xml-tag-name { + color: oklch(0.8 0.2 350); /* #f92672 pink */ +} + +.monokai .xml-attribute-name { + color: oklch(0.8 0.2 140); /* #a6e22e green */ +} + +.monokai .xml-attribute-equals { + color: oklch(0.95 0.02 100); /* White */ +} + +.monokai .xml-attribute-value { + color: oklch(0.85 0.2 90); /* #e6db74 yellow */ +} + +.monokai .xml-comment { + color: oklch(0.55 0.04 100); /* #75715e */ + font-style: italic; +} + +.monokai .xml-cdata { + color: oklch(0.75 0.2 200); /* Cyan #66d9ef */ +} + +.monokai .xml-doctype { + color: oklch(0.75 0.2 200); /* Cyan */ +} + +.monokai .xml-text { + color: oklch(0.95 0.02 100); /* White */ +} + +/* Tokyo Night theme */ +.tokyonight .xml-highlight { + color: oklch(0.85 0.02 250); /* #a9b1d6 */ +} + +.tokyonight .xml-tag-bracket { + color: oklch(0.65 0.2 15); /* #f7768e red */ +} + +.tokyonight .xml-tag-name { + color: oklch(0.65 0.2 15); /* Red for tags */ +} + +.tokyonight .xml-attribute-name { + color: oklch(0.7 0.2 320); /* #bb9af7 purple */ +} + +.tokyonight .xml-attribute-equals { + color: oklch(0.75 0.02 250); /* Dim text */ +} + +.tokyonight .xml-attribute-value { + color: oklch(0.75 0.18 140); /* #9ece6a green */ +} + +.tokyonight .xml-comment { + color: oklch(0.5 0.04 250); /* #565f89 */ + font-style: italic; +} + +.tokyonight .xml-cdata { + color: oklch(0.75 0.18 200); /* #7dcfff cyan */ +} + +.tokyonight .xml-doctype { + color: oklch(0.7 0.18 280); /* #7aa2f7 blue */ +} + +.tokyonight .xml-text { + color: oklch(0.85 0.02 250); /* Text color */ +} + +/* Solarized theme */ +.solarized .xml-highlight { + color: oklch(0.75 0.02 90); /* #839496 */ +} + +.solarized .xml-tag-bracket { + color: oklch(0.65 0.15 220); /* #268bd2 blue */ +} + +.solarized .xml-tag-name { + color: oklch(0.65 0.15 220); /* Blue for tags */ +} + +.solarized .xml-attribute-name { + color: oklch(0.6 0.18 180); /* #2aa198 cyan */ +} + +.solarized .xml-attribute-equals { + color: oklch(0.75 0.02 90); /* Base text */ +} + +.solarized .xml-attribute-value { + color: oklch(0.65 0.2 140); /* #859900 green */ +} + +.solarized .xml-comment { + color: oklch(0.5 0.04 200); /* #586e75 */ + font-style: italic; +} + +.solarized .xml-cdata { + color: oklch(0.6 0.18 180); /* Cyan */ +} + +.solarized .xml-doctype { + color: oklch(0.6 0.2 290); /* #6c71c4 violet */ +} + +.solarized .xml-text { + color: oklch(0.75 0.02 90); /* Base text */ +} + +/* Gruvbox theme */ +.gruvbox .xml-highlight { + color: oklch(0.85 0.05 85); /* #ebdbb2 */ +} + +.gruvbox .xml-tag-bracket { + color: oklch(0.55 0.22 25); /* #fb4934 red */ +} + +.gruvbox .xml-tag-name { + color: oklch(0.55 0.22 25); /* Red for tags */ +} + +.gruvbox .xml-attribute-name { + color: oklch(0.7 0.15 200); /* #8ec07c aqua */ +} + +.gruvbox .xml-attribute-equals { + color: oklch(0.7 0.04 85); /* Dim text */ +} + +.gruvbox .xml-attribute-value { + color: oklch(0.65 0.2 140); /* #b8bb26 green */ +} + +.gruvbox .xml-comment { + color: oklch(0.55 0.04 85); /* #928374 gray */ + font-style: italic; +} + +.gruvbox .xml-cdata { + color: oklch(0.7 0.15 200); /* Aqua */ +} + +.gruvbox .xml-doctype { + color: oklch(0.6 0.2 320); /* #d3869b purple */ +} + +.gruvbox .xml-text { + color: oklch(0.85 0.05 85); /* Foreground */ +} + +/* Catppuccin theme */ +.catppuccin .xml-highlight { + color: oklch(0.9 0.01 280); /* #cdd6f4 */ +} + +.catppuccin .xml-tag-bracket { + color: oklch(0.65 0.2 15); /* #f38ba8 red */ +} + +.catppuccin .xml-tag-name { + color: oklch(0.65 0.2 15); /* Red for tags */ +} + +.catppuccin .xml-attribute-name { + color: oklch(0.75 0.15 280); /* #cba6f7 mauve */ +} + +.catppuccin .xml-attribute-equals { + color: oklch(0.75 0.02 280); /* Subtext */ +} + +.catppuccin .xml-attribute-value { + color: oklch(0.8 0.15 160); /* #a6e3a1 green */ +} + +.catppuccin .xml-comment { + color: oklch(0.5 0.04 280); /* Overlay */ + font-style: italic; +} + +.catppuccin .xml-cdata { + color: oklch(0.75 0.15 220); /* #89b4fa blue */ +} + +.catppuccin .xml-doctype { + color: oklch(0.8 0.15 350); /* #f5c2e7 pink */ +} + +.catppuccin .xml-text { + color: oklch(0.9 0.01 280); /* Text */ +} + +/* One Dark theme */ +.onedark .xml-highlight { + color: oklch(0.85 0.02 240); /* #abb2bf */ +} + +.onedark .xml-tag-bracket { + color: oklch(0.6 0.2 20); /* #e06c75 red */ +} + +.onedark .xml-tag-name { + color: oklch(0.6 0.2 20); /* Red for tags */ +} + +.onedark .xml-attribute-name { + color: oklch(0.8 0.15 80); /* #e5c07b yellow */ +} + +.onedark .xml-attribute-equals { + color: oklch(0.7 0.02 240); /* Dim text */ +} + +.onedark .xml-attribute-value { + color: oklch(0.75 0.18 150); /* #98c379 green */ +} + +.onedark .xml-comment { + color: oklch(0.5 0.03 240); /* #5c6370 */ + font-style: italic; +} + +.onedark .xml-cdata { + color: oklch(0.7 0.15 180); /* #56b6c2 cyan */ +} + +.onedark .xml-doctype { + color: oklch(0.75 0.15 320); /* #c678dd magenta */ +} + +.onedark .xml-text { + color: oklch(0.85 0.02 240); /* Text */ +} + +/* Synthwave theme */ +.synthwave .xml-highlight { + color: oklch(0.95 0.02 320); /* Warm white */ +} + +.synthwave .xml-tag-bracket { + color: oklch(0.7 0.28 350); /* #f97e72 hot pink */ +} + +.synthwave .xml-tag-name { + color: oklch(0.7 0.28 350); /* Hot pink */ + text-shadow: 0 0 8px oklch(0.7 0.28 350 / 0.5); +} + +.synthwave .xml-attribute-name { + color: oklch(0.7 0.25 280); /* #ff7edb purple */ +} + +.synthwave .xml-attribute-equals { + color: oklch(0.8 0.02 320); /* White-ish */ +} + +.synthwave .xml-attribute-value { + color: oklch(0.85 0.2 60); /* #fede5d yellow */ + text-shadow: 0 0 5px oklch(0.85 0.2 60 / 0.3); +} + +.synthwave .xml-comment { + color: oklch(0.55 0.08 290); /* Dim purple */ + font-style: italic; +} + +.synthwave .xml-cdata { + color: oklch(0.8 0.25 200); /* #72f1b8 cyan */ +} + +.synthwave .xml-doctype { + color: oklch(0.8 0.25 200); /* Cyan */ +} + +.synthwave .xml-text { + color: oklch(0.95 0.02 320); /* White */ +} + +/* XML Editor container styles */ +.xml-editor { + position: relative; +} + +.xml-editor textarea { + z-index: 1; +} + +.xml-editor .xml-highlight { + z-index: 0; +} diff --git a/app/src/components/layout/sidebar.tsx b/app/src/components/layout/sidebar.tsx index 77e92c28..09abd4eb 100644 --- a/app/src/components/layout/sidebar.tsx +++ b/app/src/components/layout/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useEffect, useCallback } from "react"; +import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/store/app-store"; import { @@ -39,6 +39,7 @@ import { Atom, Radio, Monitor, + Search, } from "lucide-react"; import { DropdownMenu, @@ -109,15 +110,15 @@ interface NavItem { // Sortable Project Item Component interface SortableProjectItemProps { project: Project; - index: number; currentProjectId: string | undefined; + isHighlighted: boolean; onSelect: (project: Project) => void; } function SortableProjectItem({ project, - index, currentProjectId, + isHighlighted, onSelect, }: SortableProjectItemProps) { const { @@ -141,7 +142,8 @@ function SortableProjectItem({ style={style} className={cn( "flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-muted-foreground hover:text-foreground hover:bg-accent", - isDragging && "bg-accent shadow-lg" + isDragging && "bg-accent shadow-lg", + isHighlighted && "bg-brand-500/10 text-foreground" )} data-testid={`project-option-${project.id}`} > @@ -156,16 +158,6 @@ function SortableProjectItem({ - {/* Hotkey indicator */} - {index < 9 && ( - - {index + 1} - - )} - {/* Project content - clickable area */}
(null); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); @@ -238,6 +232,43 @@ export function Sidebar() { const [generateFeatures, setGenerateFeatures] = useState(true); const [showSpecIndicator, setShowSpecIndicator] = useState(true); + // Ref for project search input + const projectSearchInputRef = useRef(null); + + // Filtered projects based on search query + const filteredProjects = useMemo(() => { + if (!projectSearchQuery.trim()) { + return projects; + } + const query = projectSearchQuery.toLowerCase(); + return projects.filter((project) => + project.name.toLowerCase().includes(query) + ); + }, [projects, projectSearchQuery]); + + // Reset selection when filtered results change + useEffect(() => { + setSelectedProjectIndex(0); + }, [filteredProjects.length, projectSearchQuery]); + + // Reset search query when dropdown closes + useEffect(() => { + if (!isProjectPickerOpen) { + setProjectSearchQuery(""); + setSelectedProjectIndex(0); + } + }, [isProjectPickerOpen]); + + // Focus the search input when dropdown opens + useEffect(() => { + if (isProjectPickerOpen) { + // Small delay to ensure the dropdown is rendered + setTimeout(() => { + projectSearchInputRef.current?.focus(); + }, 0); + } + }, [isProjectPickerOpen]); + // Sensors for drag-and-drop const sensors = useSensors( useSensor(PointerSensor, { @@ -537,39 +568,45 @@ export function Sidebar() { }, ]; - // Handler for selecting a project by number key - const selectProjectByNumber = useCallback( - (num: number) => { - const projectIndex = num - 1; - if (projectIndex >= 0 && projectIndex < projects.length) { - setCurrentProject(projects[projectIndex]); - setIsProjectPickerOpen(false); - } - }, - [projects, setCurrentProject] - ); + // Handle selecting the currently highlighted project + const selectHighlightedProject = useCallback(() => { + if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { + setCurrentProject(filteredProjects[selectedProjectIndex]); + setIsProjectPickerOpen(false); + } + }, [filteredProjects, selectedProjectIndex, setCurrentProject]); // Handle keyboard events when project picker is open useEffect(() => { if (!isProjectPickerOpen) return; const handleKeyDown = (event: KeyboardEvent) => { - const num = parseInt(event.key, 10); - if (num >= 1 && num <= 9) { - event.preventDefault(); - selectProjectByNumber(num); - } else if (event.key === "Escape") { + if (event.key === "Escape") { setIsProjectPickerOpen(false); - } else if (event.key.toLowerCase() === "p") { - // Toggle off when P is pressed while dropdown is open + } else if (event.key === "Enter") { event.preventDefault(); - setIsProjectPickerOpen(false); + selectHighlightedProject(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedProjectIndex((prev) => + prev < filteredProjects.length - 1 ? prev + 1 : prev + ); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (event.key.toLowerCase() === "p" && !event.metaKey && !event.ctrlKey) { + // Toggle off when P is pressed (not with modifiers) while dropdown is open + // Only if not typing in the search input + if (document.activeElement !== projectSearchInputRef.current) { + event.preventDefault(); + setIsProjectPickerOpen(false); + } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isProjectPickerOpen, selectProjectByNumber]); + }, [isProjectPickerOpen, selectHighlightedProject, filteredProjects.length]); // Build keyboard shortcuts for navigation const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { @@ -793,29 +830,58 @@ export function Sidebar() { align="start" data-testid="project-picker-dropdown" > - - p.id)} - strategy={verticalListSortingStrategy} + {/* Search input for type-ahead filtering */} +
+
+ + setProjectSearchQuery(e.target.value)} + className="w-full h-8 pl-7 pr-2 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0" + data-testid="project-search-input" + /> +
+
+ + {filteredProjects.length === 0 ? ( +
+ No projects found +
+ ) : ( + - {projects.map((project, index) => ( - { - setCurrentProject(p); - setIsProjectPickerOpen(false); - }} - /> - ))} -
-
+ p.id)} + strategy={verticalListSortingStrategy} + > + {filteredProjects.map((project, index) => ( + { + setCurrentProject(p); + setIsProjectPickerOpen(false); + }} + /> + ))} + + + )} + + {/* Keyboard hint */} +
+

+ ↑↓ navigate • Enter select • Esc close +

+
diff --git a/app/src/components/session-manager.tsx b/app/src/components/session-manager.tsx index b5c9670a..5d40b8ce 100644 --- a/app/src/components/session-manager.tsx +++ b/app/src/components/session-manager.tsx @@ -9,6 +9,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { @@ -241,19 +242,18 @@ export function SessionManager({
Agent Sessions {activeTab === "active" && ( - + )}
diff --git a/app/src/components/ui/category-autocomplete.tsx b/app/src/components/ui/category-autocomplete.tsx index 0f3a1c67..c0742a07 100644 --- a/app/src/components/ui/category-autocomplete.tsx +++ b/app/src/components/ui/category-autocomplete.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useState, useRef, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { Input } from "./input"; import { Check, ChevronDown } from "lucide-react"; @@ -29,6 +30,7 @@ export function CategoryAutocomplete({ const [inputValue, setInputValue] = useState(value); const [filteredSuggestions, setFilteredSuggestions] = useState([]); const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); const containerRef = useRef(null); const inputRef = useRef(null); const listRef = useRef(null); @@ -52,12 +54,39 @@ export function CategoryAutocomplete({ setHighlightedIndex(-1); }, [inputValue, suggestions]); + // Update dropdown position when open and handle scroll/resize + useEffect(() => { + const updatePosition = () => { + if (isOpen && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width, + }); + } + }; + + updatePosition(); + + if (isOpen) { + window.addEventListener("scroll", updatePosition, true); + window.addEventListener("resize", updatePosition); + return () => { + window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("resize", updatePosition); + }; + } + }, [isOpen]); + // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && - !containerRef.current.contains(event.target as Node) + !containerRef.current.contains(event.target as Node) && + listRef.current && + !listRef.current.contains(event.target as Node) ) { setIsOpen(false); } @@ -175,40 +204,47 @@ export function CategoryAutocomplete({
- {isOpen && filteredSuggestions.length > 0 && ( - - )} + {isOpen && filteredSuggestions.length > 0 && typeof document !== "undefined" && + createPortal( + , + document.body + )} ); } diff --git a/app/src/components/ui/description-image-dropzone.tsx b/app/src/components/ui/description-image-dropzone.tsx index 7e11c9e5..7df30bf3 100644 --- a/app/src/components/ui/description-image-dropzone.tsx +++ b/app/src/components/ui/description-image-dropzone.tsx @@ -31,6 +31,7 @@ interface DescriptionImageDropZoneProps { previewMap?: ImagePreviewMap; onPreviewMapChange?: (map: ImagePreviewMap) => void; autoFocus?: boolean; + error?: boolean; // Show error state with red border } const ACCEPTED_IMAGE_TYPES = [ @@ -55,6 +56,7 @@ export function DescriptionImageDropZone({ previewMap, onPreviewMapChange, autoFocus = false, + error = false, }: DescriptionImageDropZoneProps) { const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); @@ -306,6 +308,7 @@ export function DescriptionImageDropZone({ onChange={(e) => onChange(e.target.value)} disabled={disabled} autoFocus={autoFocus} + aria-invalid={error} className={cn( "min-h-[120px]", isProcessing && "opacity-50 pointer-events-none" diff --git a/app/src/components/ui/hotkey-button.tsx b/app/src/components/ui/hotkey-button.tsx new file mode 100644 index 00000000..c8511299 --- /dev/null +++ b/app/src/components/ui/hotkey-button.tsx @@ -0,0 +1,296 @@ +"use client"; + +import * as React from "react"; +import { useEffect, useCallback, useRef } from "react"; +import { Button, buttonVariants } from "./button"; +import { cn } from "@/lib/utils"; +import type { VariantProps } from "class-variance-authority"; + +export interface HotkeyConfig { + /** The key to trigger the hotkey (e.g., "Enter", "s", "n") */ + key: string; + /** Whether the Cmd/Ctrl modifier is required */ + cmdCtrl?: boolean; + /** Whether the Shift modifier is required */ + shift?: boolean; + /** Whether the Alt/Option modifier is required */ + alt?: boolean; + /** Custom display label for the hotkey (overrides auto-generated label) */ + label?: string; +} + +export interface HotkeyButtonProps + extends React.ComponentProps<"button">, + VariantProps { + /** Hotkey configuration - can be a simple key string or a full config object */ + hotkey?: string | HotkeyConfig; + /** Whether to show the hotkey indicator badge */ + showHotkeyIndicator?: boolean; + /** Whether the hotkey listener is active (registers keyboard listener). Set to false if hotkey is already handled elsewhere. */ + hotkeyActive?: boolean; + /** Optional scope element ref - hotkey will only work when this element is visible */ + scopeRef?: React.RefObject; + /** Callback when hotkey is triggered */ + onHotkeyTrigger?: () => void; + /** Whether to use the Slot component for composition */ + asChild?: boolean; +} + +/** + * Get the modifier key symbol based on platform + */ +function getModifierSymbol(isMac: boolean): string { + return isMac ? "⌘" : "Ctrl"; +} + +/** + * Parse hotkey config into a normalized format + */ +function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig { + if (typeof hotkey === "string") { + return { key: hotkey }; + } + return hotkey; +} + +/** + * Generate the display label for the hotkey + */ +function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode { + if (config.label) { + return config.label; + } + + const parts: React.ReactNode[] = []; + + if (config.cmdCtrl) { + parts.push( + + {getModifierSymbol(isMac)} + + ); + } + + if (config.shift) { + parts.push( + + ⇧ + + ); + } + + if (config.alt) { + parts.push( + + {isMac ? "⌥" : "Alt"} + + ); + } + + // Convert key to display format + let keyDisplay = config.key; + switch (config.key.toLowerCase()) { + case "enter": + keyDisplay = "↵"; + break; + case "escape": + case "esc": + keyDisplay = "Esc"; + break; + case "arrowup": + keyDisplay = "↑"; + break; + case "arrowdown": + keyDisplay = "↓"; + break; + case "arrowleft": + keyDisplay = "←"; + break; + case "arrowright": + keyDisplay = "→"; + break; + case "backspace": + keyDisplay = "⌫"; + break; + case "delete": + keyDisplay = "⌦"; + break; + case "tab": + keyDisplay = "⇥"; + break; + case " ": + keyDisplay = "Space"; + break; + default: + // Capitalize single letters + if (config.key.length === 1) { + keyDisplay = config.key.toUpperCase(); + } + } + + parts.push( + + {keyDisplay} + + ); + + return ( + + {parts} + + ); +} + +/** + * Check if an element is a form input + */ +function isInputElement(element: Element | null): boolean { + if (!element) return false; + + const tagName = element.tagName.toLowerCase(); + if (tagName === "input" || tagName === "textarea" || tagName === "select") { + return true; + } + + if (element.getAttribute("contenteditable") === "true") { + return true; + } + + const role = element.getAttribute("role"); + if (role === "textbox" || role === "searchbox" || role === "combobox") { + return true; + } + + return false; +} + +/** + * A button component that supports keyboard hotkeys + * + * Features: + * - Automatic hotkey listening when mounted + * - Visual hotkey indicator badge + * - Support for modifier keys (Cmd/Ctrl, Shift, Alt) + * - Respects focus context (doesn't trigger when typing in inputs) + * - Scoped activation via scopeRef + */ +export function HotkeyButton({ + hotkey, + showHotkeyIndicator = true, + hotkeyActive = true, + scopeRef, + onHotkeyTrigger, + onClick, + disabled, + children, + className, + variant, + size, + asChild = false, + ...props +}: HotkeyButtonProps) { + const buttonRef = useRef(null); + const [isMac, setIsMac] = React.useState(true); + + // Detect platform on mount + useEffect(() => { + setIsMac(navigator.platform.toLowerCase().includes("mac")); + }, []); + + const config = hotkey ? parseHotkeyConfig(hotkey) : null; + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!config || !hotkeyActive || disabled) return; + + // Don't trigger when typing in inputs (unless explicitly scoped) + if (!scopeRef && isInputElement(document.activeElement)) { + return; + } + + // Check modifier keys + const cmdCtrlPressed = event.metaKey || event.ctrlKey; + const shiftPressed = event.shiftKey; + const altPressed = event.altKey; + + // Validate modifier requirements + if (config.cmdCtrl && !cmdCtrlPressed) return; + if (!config.cmdCtrl && cmdCtrlPressed) return; + if (config.shift && !shiftPressed) return; + if (!config.shift && shiftPressed) return; + if (config.alt && !altPressed) return; + if (!config.alt && altPressed) return; + + // Check if the key matches + if (event.key.toLowerCase() !== config.key.toLowerCase()) return; + + // If scoped, check that the scope element is visible + if (scopeRef && scopeRef.current) { + const scopeEl = scopeRef.current; + const isVisible = scopeEl.offsetParent !== null || + getComputedStyle(scopeEl).display !== "none"; + if (!isVisible) return; + } + + event.preventDefault(); + event.stopPropagation(); + + // Trigger the click handler or custom onHotkeyTrigger + if (onHotkeyTrigger) { + onHotkeyTrigger(); + } else if (onClick) { + onClick(event as unknown as React.MouseEvent); + } else if (buttonRef.current) { + buttonRef.current.click(); + } + }, + [config, hotkeyActive, disabled, scopeRef, onHotkeyTrigger, onClick] + ); + + // Set up global key listener + useEffect(() => { + if (!config || !hotkeyActive) return; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [config, hotkeyActive, handleKeyDown]); + + // Render the hotkey indicator + const hotkeyIndicator = config && showHotkeyIndicator ? ( + + {getHotkeyDisplayLabel(config, isMac)} + + ) : null; + + return ( + + ); +} + +export { getHotkeyDisplayLabel, parseHotkeyConfig }; diff --git a/app/src/components/ui/image-drop-zone.tsx b/app/src/components/ui/image-drop-zone.tsx index 0109d245..17b4c9d0 100644 --- a/app/src/components/ui/image-drop-zone.tsx +++ b/app/src/components/ui/image-drop-zone.tsx @@ -12,6 +12,7 @@ interface ImageDropZoneProps { className?: string; children?: React.ReactNode; disabled?: boolean; + images?: ImageAttachment[]; // Optional controlled images prop } const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; @@ -24,12 +25,24 @@ export function ImageDropZone({ className, children, disabled = false, + images, }: ImageDropZoneProps) { const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); - const [selectedImages, setSelectedImages] = useState([]); + const [internalImages, setInternalImages] = useState([]); const fileInputRef = useRef(null); + // Use controlled images if provided, otherwise use internal state + const selectedImages = images ?? internalImages; + + // Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state + const updateImages = useCallback((newImages: ImageAttachment[]) => { + if (images === undefined) { + setInternalImages(newImages); + } + onImagesSelected(newImages); + }, [images, onImagesSelected]); + const processFiles = useCallback(async (files: FileList) => { if (disabled || isProcessing) return; @@ -79,12 +92,11 @@ export function ImageDropZone({ if (newImages.length > 0) { const allImages = [...selectedImages, ...newImages]; - setSelectedImages(allImages); - onImagesSelected(allImages); + updateImages(allImages); } setIsProcessing(false); - }, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, onImagesSelected]); + }, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -132,14 +144,12 @@ export function ImageDropZone({ const removeImage = useCallback((imageId: string) => { const updated = selectedImages.filter(img => img.id !== imageId); - setSelectedImages(updated); - onImagesSelected(updated); - }, [selectedImages, onImagesSelected]); + updateImages(updated); + }, [selectedImages, updateImages]); const clearAllImages = useCallback(() => { - setSelectedImages([]); - onImagesSelected([]); - }, [onImagesSelected]); + updateImages([]); + }, [updateImages]); return (
diff --git a/app/src/components/ui/input.tsx b/app/src/components/ui/input.tsx index 89169058..0fc4a3dd 100644 --- a/app/src/components/ui/input.tsx +++ b/app/src/components/ui/input.tsx @@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", - "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "aria-invalid:ring-destructive/20 aria-invalid:border-destructive", className )} {...props} diff --git a/app/src/components/ui/xml-syntax-editor.tsx b/app/src/components/ui/xml-syntax-editor.tsx new file mode 100644 index 00000000..93cda161 --- /dev/null +++ b/app/src/components/ui/xml-syntax-editor.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useRef, useCallback, useMemo } from "react"; +import { cn } from "@/lib/utils"; + +interface XmlSyntaxEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + "data-testid"?: string; +} + +// Tokenize XML content into parts for highlighting +interface Token { + type: + | "tag-bracket" + | "tag-name" + | "attribute-name" + | "attribute-equals" + | "attribute-value" + | "text" + | "comment" + | "cdata" + | "doctype"; + value: string; +} + +function tokenizeXml(text: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + while (i < text.length) { + // Comment: + if (text.slice(i, i + 4) === "", i + 4); + if (end !== -1) { + tokens.push({ type: "comment", value: text.slice(i, end + 3) }); + i = end + 3; + continue; + } + } + + // CDATA: + if (text.slice(i, i + 9) === "", i + 9); + if (end !== -1) { + tokens.push({ type: "cdata", value: text.slice(i, end + 3) }); + i = end + 3; + continue; + } + } + + // DOCTYPE: + if (text.slice(i, i + 9).toUpperCase() === "", i + 9); + if (end !== -1) { + tokens.push({ type: "doctype", value: text.slice(i, end + 1) }); + i = end + 1; + continue; + } + } + + // Tag: < ... > + if (text[i] === "<") { + // Find the end of the tag + let tagEnd = i + 1; + let inString: string | null = null; + + while (tagEnd < text.length) { + const char = text[tagEnd]; + + if (inString) { + if (char === inString && text[tagEnd - 1] !== "\\") { + inString = null; + } + } else { + if (char === '"' || char === "'") { + inString = char; + } else if (char === ">") { + tagEnd++; + break; + } + } + tagEnd++; + } + + const tagContent = text.slice(i, tagEnd); + const tagTokens = tokenizeTag(tagContent); + tokens.push(...tagTokens); + i = tagEnd; + continue; + } + + // Text content between tags + const nextTag = text.indexOf("<", i); + if (nextTag === -1) { + tokens.push({ type: "text", value: text.slice(i) }); + break; + } else if (nextTag > i) { + tokens.push({ type: "text", value: text.slice(i, nextTag) }); + i = nextTag; + } + } + + return tokens; +} + +function tokenizeTag(tag: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + // Opening bracket (< or " || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") { + tokens.push({ type: "tag-bracket", value: tag.slice(i) }); + break; + } + + // Attribute name + let attrName = ""; + while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) { + attrName += tag[i]; + i++; + } + if (attrName) { + tokens.push({ type: "attribute-name", value: attrName }); + } + + // Skip whitespace around = + while (i < tag.length && /\s/.test(tag[i])) { + tokens.push({ type: "text", value: tag[i] }); + i++; + } + + // Equals sign + if (tag[i] === "=") { + tokens.push({ type: "attribute-equals", value: "=" }); + i++; + } + + // Skip whitespace after = + while (i < tag.length && /\s/.test(tag[i])) { + tokens.push({ type: "text", value: tag[i] }); + i++; + } + + // Attribute value + if (tag[i] === '"' || tag[i] === "'") { + const quote = tag[i]; + let value = quote; + i++; + while (i < tag.length && tag[i] !== quote) { + value += tag[i]; + i++; + } + if (i < tag.length) { + value += tag[i]; + i++; + } + tokens.push({ type: "attribute-value", value }); + } + } + + return tokens; +} + +export function XmlSyntaxEditor({ + value, + onChange, + placeholder, + className, + "data-testid": testId, +}: XmlSyntaxEditorProps) { + const textareaRef = useRef(null); + const highlightRef = useRef(null); + + // Sync scroll between textarea and highlight layer + const handleScroll = useCallback(() => { + if (textareaRef.current && highlightRef.current) { + highlightRef.current.scrollTop = textareaRef.current.scrollTop; + highlightRef.current.scrollLeft = textareaRef.current.scrollLeft; + } + }, []); + + // Handle tab key for indentation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + const textarea = e.currentTarget; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newValue = + value.substring(0, start) + " " + value.substring(end); + onChange(newValue); + // Reset cursor position after state update + requestAnimationFrame(() => { + textarea.selectionStart = textarea.selectionEnd = start + 2; + }); + } + }, + [value, onChange] + ); + + // Memoize the highlighted content + const highlightedContent = useMemo(() => { + const tokens = tokenizeXml(value); + + return tokens.map((token, index) => { + const className = `xml-${token.type}`; + // React handles escaping automatically, just render the raw value + return ( + + {token.value} + + ); + }); + }, [value]); + + return ( +
+ {/* Syntax highlighted layer (read-only, behind textarea) */} + + + {/* Actual textarea (transparent text, handles input) */} +