mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
<HotkeyButton
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
hotkey={ACTION_SHORTCUTS.addFeature}
|
||||
hotkeyActive={false} // <-- Important! Prevents duplicate listener
|
||||
>
|
||||
Add Feature
|
||||
</HotkeyButton>
|
||||
|
||||
// HotkeyButton should only actively listen when it's the sole handler (e.g., Cmd+Enter in dialogs)
|
||||
<HotkeyButton
|
||||
onClick={handleSubmit}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={isDialogOpen} // Active when dialog is open
|
||||
>
|
||||
Submit
|
||||
</HotkeyButton>
|
||||
```
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Hotkey indicator */}
|
||||
{index < 9 && (
|
||||
<span
|
||||
className="flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||
data-testid={`project-hotkey-${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Project content - clickable area */}
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 min-w-0"
|
||||
@@ -223,6 +215,8 @@ export function Sidebar() {
|
||||
|
||||
// State for project picker dropdown
|
||||
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState("");
|
||||
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
||||
const [showTrashDialog, setShowTrashDialog] = useState(false);
|
||||
const [activeTrashId, setActiveTrashId] = useState<string | null>(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<HTMLInputElement>(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"
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={projects.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
{/* Search input for type-ahead filtering */}
|
||||
<div className="px-2 pb-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
ref={projectSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={projectSearchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
No projects found
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{projects.map((project, index) => (
|
||||
<SortableProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
index={index}
|
||||
currentProjectId={currentProject?.id}
|
||||
onSelect={(p) => {
|
||||
setCurrentProject(p);
|
||||
setIsProjectPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<SortableContext
|
||||
items={filteredProjects.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filteredProjects.map((project, index) => (
|
||||
<SortableProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
currentProjectId={currentProject?.id}
|
||||
isHighlighted={index === selectedProjectIndex}
|
||||
onSelect={(p) => {
|
||||
setCurrentProject(p);
|
||||
setIsProjectPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<div className="px-2 pt-2 mt-1 border-t border-border">
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<CardTitle>Agent Sessions</CardTitle>
|
||||
{activeTab === "active" && (
|
||||
<Button
|
||||
<HotkeyButton
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleQuickCreateSession}
|
||||
hotkey={ACTION_SHORTCUTS.newSession}
|
||||
hotkeyActive={false}
|
||||
data-testid="new-session-button"
|
||||
title={`New Session (${ACTION_SHORTCUTS.newSession})`}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
New
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground">
|
||||
{ACTION_SHORTCUTS.newSession}
|
||||
</span>
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(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({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && filteredSuggestions.length > 0 && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
role="listbox"
|
||||
data-testid="category-autocomplete-list"
|
||||
>
|
||||
{filteredSuggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={suggestion}
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
|
||||
highlightedIndex === index && "bg-accent text-accent-foreground",
|
||||
inputValue === suggestion && "font-medium"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(suggestion);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{inputValue === suggestion && (
|
||||
<Check className="mr-2 h-4 w-4 text-primary" />
|
||||
)}
|
||||
<span className={cn(inputValue !== suggestion && "ml-6")}>
|
||||
{suggestion}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isOpen && filteredSuggestions.length > 0 && typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="fixed z-[9999] max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
role="listbox"
|
||||
data-testid="category-autocomplete-list"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
{filteredSuggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={suggestion}
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
|
||||
highlightedIndex === index && "bg-accent text-accent-foreground",
|
||||
inputValue === suggestion && "font-medium"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(suggestion);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{inputValue === suggestion && (
|
||||
<Check className="mr-2 h-4 w-4 text-primary" />
|
||||
)}
|
||||
<span className={cn(inputValue !== suggestion && "ml-6")}>
|
||||
{suggestion}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
296
app/src/components/ui/hotkey-button.tsx
Normal file
296
app/src/components/ui/hotkey-button.tsx
Normal file
@@ -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<typeof buttonVariants> {
|
||||
/** 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<HTMLElement | null>;
|
||||
/** 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(
|
||||
<span key="mod" className="leading-none flex items-center justify-center">
|
||||
{getModifierSymbol(isMac)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.shift) {
|
||||
parts.push(
|
||||
<span key="shift" className="leading-none flex items-center justify-center">
|
||||
⇧
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.alt) {
|
||||
parts.push(
|
||||
<span key="alt" className="leading-none flex items-center justify-center">
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
<span key="key" className="leading-none flex items-center justify-center">
|
||||
{keyDisplay}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLButtonElement>(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<HTMLButtonElement>);
|
||||
} 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 ? (
|
||||
<span
|
||||
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
|
||||
data-testid="hotkey-indicator"
|
||||
>
|
||||
{getHotkeyDisplayLabel(config, isMac)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(className)}
|
||||
asChild={asChild}
|
||||
{...props}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<>
|
||||
{children}
|
||||
{hotkeyIndicator}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
{hotkeyIndicator}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { getHotkeyDisplayLabel, parseHotkeyConfig };
|
||||
@@ -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<ImageAttachment[]>([]);
|
||||
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className={cn("relative", className)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
290
app/src/components/ui/xml-syntax-editor.tsx
Normal file
290
app/src/components/ui/xml-syntax-editor.tsx
Normal file
@@ -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) === "<!--") {
|
||||
const end = text.indexOf("-->", i + 4);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// CDATA: <![CDATA[ ... ]]>
|
||||
if (text.slice(i, i + 9) === "<![CDATA[") {
|
||||
const end = text.indexOf("]]>", i + 9);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// DOCTYPE: <!DOCTYPE ... >
|
||||
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
|
||||
const end = text.indexOf(">", 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 </ or <?)
|
||||
if (tag.startsWith("</")) {
|
||||
tokens.push({ type: "tag-bracket", value: "</" });
|
||||
i = 2;
|
||||
} else if (tag.startsWith("<?")) {
|
||||
tokens.push({ type: "tag-bracket", value: "<?" });
|
||||
i = 2;
|
||||
} else {
|
||||
tokens.push({ type: "tag-bracket", value: "<" });
|
||||
i = 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
tokens.push({ type: "text", value: tag[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Tag name
|
||||
let tagName = "";
|
||||
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
|
||||
tagName += tag[i];
|
||||
i++;
|
||||
}
|
||||
if (tagName) {
|
||||
tokens.push({ type: "tag-name", value: tagName });
|
||||
}
|
||||
|
||||
// Attributes and closing
|
||||
while (i < tag.length) {
|
||||
// Skip whitespace
|
||||
if (/\s/.test(tag[i])) {
|
||||
let ws = "";
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
ws += tag[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "text", value: ws });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Closing bracket
|
||||
if (tag[i] === ">" || 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<HTMLTextAreaElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<span key={index} className={className}>
|
||||
{token.value}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full h-full xml-editor", className)}>
|
||||
{/* Syntax highlighted layer (read-only, behind textarea) */}
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{value ? (
|
||||
<code className="xml-highlight">{highlightedContent}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground opacity-50">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actual textarea (transparent text, handles input) */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
spellCheck={false}
|
||||
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
|
||||
data-testid={testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -641,11 +641,12 @@ export function AgentView() {
|
||||
|
||||
{/* Input */}
|
||||
{currentSessionId && (
|
||||
<div className="border-t p-4 space-y-3">
|
||||
<div className="border-t border-border p-4 space-y-3 bg-background">
|
||||
{/* Image Drop Zone (when visible) */}
|
||||
{showImageDropZone && (
|
||||
<ImageDropZone
|
||||
onImagesSelected={handleImagesSelected}
|
||||
images={selectedImages}
|
||||
maxFiles={5}
|
||||
className="mb-3"
|
||||
disabled={isProcessing || !isConnected}
|
||||
@@ -657,7 +658,7 @@ export function AgentView() {
|
||||
className={cn(
|
||||
"flex gap-2 transition-all duration-200 rounded-lg",
|
||||
isDragOver &&
|
||||
"bg-blue-50 dark:bg-blue-950/20 ring-2 ring-blue-400 ring-offset-2"
|
||||
"bg-primary/10 ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -679,20 +680,21 @@ export function AgentView() {
|
||||
disabled={isProcessing || !isConnected}
|
||||
data-testid="agent-input"
|
||||
className={cn(
|
||||
"bg-input border-border",
|
||||
selectedImages.length > 0 &&
|
||||
"border-blue-200 bg-blue-50/50 dark:bg-blue-950/20",
|
||||
"border-primary/50 bg-primary/5",
|
||||
isDragOver &&
|
||||
"border-blue-400 bg-blue-50/50 dark:bg-blue-950/20"
|
||||
"border-primary bg-primary/10"
|
||||
)}
|
||||
/>
|
||||
{selectedImages.length > 0 && !isDragOver && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-blue-600 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded">
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-blue-600 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded flex items-center gap-1">
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded flex items-center gap-1">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
Drop here
|
||||
</div>
|
||||
@@ -707,8 +709,8 @@ export function AgentView() {
|
||||
disabled={isProcessing || !isConnected}
|
||||
className={cn(
|
||||
showImageDropZone &&
|
||||
"bg-blue-100 text-blue-600 dark:bg-blue-900/50 dark:text-blue-400",
|
||||
selectedImages.length > 0 && "border-blue-400"
|
||||
"bg-primary/20 text-primary border-primary",
|
||||
selectedImages.length > 0 && "border-primary"
|
||||
)}
|
||||
title="Attach images"
|
||||
>
|
||||
|
||||
@@ -34,6 +34,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 { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -79,9 +80,20 @@ import {
|
||||
Sparkles,
|
||||
UserCircle,
|
||||
Lightbulb,
|
||||
Search,
|
||||
X,
|
||||
Minimize2,
|
||||
Square,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
import {
|
||||
@@ -188,6 +200,8 @@ export function BoardView() {
|
||||
useWorktrees,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
kanbanCardDetailLevel,
|
||||
setKanbanCardDetailLevel,
|
||||
} = useAppStore();
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
||||
@@ -234,6 +248,10 @@ export function BoardView() {
|
||||
import("@/lib/electron").FeatureSuggestion[]
|
||||
>([]);
|
||||
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
|
||||
// Search filter for Kanban cards
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Validation state for add feature form
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
@@ -290,6 +308,9 @@ export function BoardView() {
|
||||
// Ref to hold the start next callback (to avoid dependency issues)
|
||||
const startNextFeaturesRef = useRef<() => void>(() => {});
|
||||
|
||||
// Ref for search input to enable keyboard shortcut focus
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keyboard shortcuts for this view
|
||||
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcuts: KeyboardShortcut[] = [
|
||||
@@ -303,6 +324,11 @@ export function BoardView() {
|
||||
action: () => startNextFeaturesRef.current(),
|
||||
description: "Start next features from backlog",
|
||||
},
|
||||
{
|
||||
key: "/",
|
||||
action: () => searchInputRef.current?.focus(),
|
||||
description: "Focus search input",
|
||||
},
|
||||
];
|
||||
|
||||
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
|
||||
@@ -660,9 +686,13 @@ export function BoardView() {
|
||||
|
||||
// Determine if dragging is allowed based on status and skipTests
|
||||
// - Backlog items can always be dragged
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
// - skipTests (non-TDD) items can be dragged between in_progress and verified
|
||||
// - Non-skipTests (TDD) items that are in progress or verified cannot be dragged
|
||||
if (draggedFeature.status !== "backlog") {
|
||||
if (
|
||||
draggedFeature.status !== "backlog" &&
|
||||
draggedFeature.status !== "waiting_approval"
|
||||
) {
|
||||
// Only allow dragging in_progress/verified if it's a skipTests feature and not currently running
|
||||
if (!draggedFeature.skipTests || isRunningTask) {
|
||||
console.log(
|
||||
@@ -720,6 +750,28 @@ export function BoardView() {
|
||||
} else {
|
||||
moveFeature(featureId, targetStatus);
|
||||
}
|
||||
} else if (draggedFeature.status === "waiting_approval") {
|
||||
// waiting_approval features can be dragged to verified for manual verification
|
||||
// NOTE: This check must come BEFORE skipTests check because waiting_approval
|
||||
// features often have skipTests=true, and we want status-based handling first
|
||||
if (targetStatus === "verified") {
|
||||
moveFeature(featureId, "verified");
|
||||
toast.success("Feature verified", {
|
||||
description: `Manually verified: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving waiting_approval cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
} else if (draggedFeature.skipTests) {
|
||||
// skipTests feature being moved between in_progress and verified
|
||||
if (
|
||||
@@ -763,6 +815,11 @@ export function BoardView() {
|
||||
};
|
||||
|
||||
const handleAddFeature = () => {
|
||||
// Validate description is required
|
||||
if (!newFeature.description.trim()) {
|
||||
setDescriptionError(true);
|
||||
return;
|
||||
}
|
||||
const category = newFeature.category || "Uncategorized";
|
||||
const selectedModel = newFeature.model;
|
||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||
@@ -1288,7 +1345,17 @@ export function BoardView() {
|
||||
verified: [],
|
||||
};
|
||||
|
||||
features.forEach((f) => {
|
||||
// Filter features by search query (case-insensitive)
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
const filteredFeatures = normalizedQuery
|
||||
? features.filter(
|
||||
(f) =>
|
||||
f.description.toLowerCase().includes(normalizedQuery) ||
|
||||
f.category.toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
: features;
|
||||
|
||||
filteredFeatures.forEach((f) => {
|
||||
// If feature has a running agent, always show it in "in_progress"
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
if (isRunning) {
|
||||
@@ -1300,7 +1367,7 @@ export function BoardView() {
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks]);
|
||||
}, [features, runningAutoTasks, searchQuery]);
|
||||
|
||||
const getColumnFeatures = useCallback(
|
||||
(columnId: ColumnId) => {
|
||||
@@ -1556,27 +1623,123 @@ export function BoardView() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
hotkey={ACTION_SHORTCUTS.addFeature}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span
|
||||
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground inline-flex items-center justify-center"
|
||||
data-testid="shortcut-add-feature"
|
||||
>
|
||||
{ACTION_SHORTCUTS.addFeature}
|
||||
</span>
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Search Bar Row */}
|
||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search features by keyword..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-12 border-border"
|
||||
data-testid="kanban-search-input"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid="kanban-search-clear"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||
data-testid="kanban-search-hotkey"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kanban Card Detail Level Toggle */}
|
||||
{isMounted && (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border ml-4"
|
||||
data-testid="kanban-detail-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("minimal")}
|
||||
className={cn(
|
||||
"p-2 rounded-l-lg transition-colors",
|
||||
kanbanCardDetailLevel === "minimal"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-minimal"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Minimal - Title & category only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("standard")}
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
kanbanCardDetailLevel === "standard"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-standard"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Standard - Steps & progress</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("detailed")}
|
||||
className={cn(
|
||||
"p-2 rounded-r-lg transition-colors",
|
||||
kanbanCardDetailLevel === "detailed"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-detailed"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Detailed - Model, tools & tasks</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{/* Kanban Columns */}
|
||||
<div className="flex-1 overflow-x-auto p-4">
|
||||
<div className="flex-1 overflow-x-auto px-4 pb-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
@@ -1626,19 +1789,18 @@ export function BoardView() {
|
||||
)}
|
||||
</Button>
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
<HotkeyButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||
onClick={handleStartNextFeatures}
|
||||
hotkey={ACTION_SHORTCUTS.startNext}
|
||||
hotkeyActive={false}
|
||||
data-testid="start-next-button"
|
||||
>
|
||||
<FastForward className="w-3 h-3 mr-1" />
|
||||
Start Next
|
||||
<span className="ml-1 px-1 py-0.5 text-[9px] font-mono rounded bg-accent border border-border-glass">
|
||||
{ACTION_SHORTCUTS.startNext}
|
||||
</span>
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -1707,25 +1869,16 @@ export function BoardView() {
|
||||
{/* Add Feature Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={(open) => {
|
||||
setShowAddDialog(open);
|
||||
// Clear preview map and reset advanced options when dialog closes
|
||||
// Clear preview map, validation error, and reset advanced options when dialog closes
|
||||
if (!open) {
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setDescriptionError(false);
|
||||
}
|
||||
}}>
|
||||
<DialogContent
|
||||
compact={!isMaximized}
|
||||
data-testid="add-feature-dialog"
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key === "Enter" &&
|
||||
newFeature.description
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleAddFeature();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Feature</DialogTitle>
|
||||
@@ -1755,9 +1908,12 @@ export function BoardView() {
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<DescriptionImageDropZone
|
||||
value={newFeature.description}
|
||||
onChange={(value) =>
|
||||
setNewFeature({ ...newFeature, description: value })
|
||||
}
|
||||
onChange={(value) => {
|
||||
setNewFeature({ ...newFeature, description: value });
|
||||
if (value.trim()) {
|
||||
setDescriptionError(false);
|
||||
}
|
||||
}}
|
||||
images={newFeature.imagePaths}
|
||||
onImagesChange={(images) =>
|
||||
setNewFeature({ ...newFeature, imagePaths: images })
|
||||
@@ -1766,6 +1922,7 @@ export function BoardView() {
|
||||
previewMap={newFeaturePreviewMap}
|
||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||
autoFocus
|
||||
error={descriptionError}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -2057,20 +2214,14 @@ export function BoardView() {
|
||||
<Button variant="ghost" onClick={() => setShowAddDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleAddFeature}
|
||||
disabled={!newFeature.description}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showAddDialog}
|
||||
data-testid="confirm-add-feature"
|
||||
>
|
||||
Add Feature
|
||||
<span
|
||||
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
|
||||
data-testid="shortcut-confirm-add-feature"
|
||||
>
|
||||
<span className="leading-none flex items-center justify-center">⌘</span>
|
||||
<span className="leading-none flex items-center justify-center">↵</span>
|
||||
</span>
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -2414,12 +2565,14 @@ export function BoardView() {
|
||||
<Button variant="ghost" onClick={() => setEditingFeature(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleUpdateFeature}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={!!editingFeature}
|
||||
data-testid="confirm-edit-feature"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -2584,17 +2737,16 @@ export function BoardView() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleSendFollowUp}
|
||||
disabled={!followUpPrompt.trim()}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showFollowUpDialog}
|
||||
data-testid="confirm-follow-up"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Send Follow-Up
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20">
|
||||
⌘↵
|
||||
</span>
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Plus,
|
||||
@@ -363,20 +364,16 @@ export function ContextView() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
hotkey={ACTION_SHORTCUTS.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-context-file"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add File
|
||||
<span
|
||||
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-secondary border border-border"
|
||||
data-testid="shortcut-add-context-file"
|
||||
>
|
||||
{ACTION_SHORTCUTS.addContextFile}
|
||||
</span>
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -650,16 +647,18 @@ export function ContextView() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleAddFile}
|
||||
disabled={
|
||||
!newFileName.trim() ||
|
||||
(newFileType === "image" && !uploadedImageData)
|
||||
}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={isAddDialogOpen}
|
||||
data-testid="confirm-add-file"
|
||||
>
|
||||
Add File
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -426,9 +427,11 @@ export function FeatureSuggestionsDialog({
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleImport}
|
||||
disabled={selectedIds.size === 0 || isImporting}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open && hasSuggestions}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
@@ -437,7 +440,7 @@ export function FeatureSuggestionsDialog({
|
||||
)}
|
||||
Import {selectedIds.size} Feature
|
||||
{selectedIds.size !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -188,9 +188,12 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
// Dragging logic:
|
||||
// - Backlog items can always be dragged
|
||||
// - skipTests items can be dragged even when in_progress or verified (unless currently running)
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
// - Non-skipTests (TDD) items in progress or verified cannot be dragged
|
||||
const isDraggable =
|
||||
feature.status === "backlog" || (feature.skipTests && !isCurrentAutoTask);
|
||||
feature.status === "backlog" ||
|
||||
feature.status === "waiting_approval" ||
|
||||
(feature.skipTests && !isCurrentAutoTask);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -336,7 +339,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<Edit className="w-3 h-3 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{onViewOutput && (
|
||||
{onViewOutput && feature.status !== "backlog" && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -737,25 +740,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||
<>
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
data-testid={`view-logs-backlog-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Logs
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useAppStore, AIProfile, AgentModel, ThinkingLevel, ModelProvider } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -236,11 +237,13 @@ function ProfileForm({
|
||||
onSave,
|
||||
onCancel,
|
||||
isEditing,
|
||||
hotkeyActive,
|
||||
}: {
|
||||
profile: Partial<AIProfile>;
|
||||
onSave: (profile: Omit<AIProfile, "id">) => void;
|
||||
onCancel: () => void;
|
||||
isEditing: boolean;
|
||||
hotkeyActive: boolean;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: profile.name || "",
|
||||
@@ -429,9 +432,14 @@ function ProfileForm({
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} data-testid="save-profile-button">
|
||||
<HotkeyButton
|
||||
onClick={handleSubmit}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={hotkeyActive}
|
||||
data-testid="save-profile-button"
|
||||
>
|
||||
{isEditing ? "Save Changes" : "Create Profile"}
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
@@ -545,13 +553,15 @@ export function ProfilesView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddDialog(true)} data-testid="add-profile-button" className="relative">
|
||||
<HotkeyButton
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
hotkey={ACTION_SHORTCUTS.addProfile}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-profile-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Profile
|
||||
<span className="hidden lg:flex items-center justify-center ml-2 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground">
|
||||
{ACTION_SHORTCUTS.addProfile}
|
||||
</span>
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -662,6 +672,7 @@ export function ProfilesView() {
|
||||
onSave={handleAddProfile}
|
||||
onCancel={() => setShowAddDialog(false)}
|
||||
isEditing={false}
|
||||
hotkeyActive={showAddDialog}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -682,6 +693,7 @@ export function ProfilesView() {
|
||||
onSave={handleUpdateProfile}
|
||||
onCancel={() => setEditingProfile(null)}
|
||||
isEditing={true}
|
||||
hotkeyActive={!!editingProfile}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function SettingsView() {
|
||||
setCurrentView,
|
||||
theme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
kanbanCardDetailLevel,
|
||||
setKanbanCardDetailLevel,
|
||||
defaultSkipTests,
|
||||
@@ -79,6 +80,18 @@ export function SettingsView() {
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
} = useAppStore();
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
const effectiveTheme = currentProject?.theme || theme;
|
||||
|
||||
// Handler to set theme - saves to project if one is selected, otherwise to global
|
||||
const handleSetTheme = (newTheme: typeof theme) => {
|
||||
if (currentProject) {
|
||||
setProjectTheme(currentProject.id, newTheme);
|
||||
} else {
|
||||
setTheme(newTheme);
|
||||
}
|
||||
};
|
||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
||||
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
||||
@@ -171,13 +184,28 @@ export function SettingsView() {
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const sections = NAV_ITEMS.map((item) => ({
|
||||
id: item.id,
|
||||
element: document.getElementById(item.id),
|
||||
})).filter((s) => s.element);
|
||||
const sections = NAV_ITEMS.filter(
|
||||
(item) => item.id !== "danger" || currentProject
|
||||
)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
element: document.getElementById(item.id),
|
||||
}))
|
||||
.filter((s) => s.element);
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
|
||||
// Check if scrolled to bottom (within a small threshold)
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
|
||||
if (isAtBottom && sections.length > 0) {
|
||||
// If at bottom, highlight the last visible section
|
||||
setActiveSection(sections[sections.length - 1].id);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = sections.length - 1; i >= 0; i--) {
|
||||
const section = sections[i];
|
||||
@@ -194,7 +222,7 @@ export function SettingsView() {
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
}, [currentProject]);
|
||||
|
||||
const scrollToSection = useCallback((sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
@@ -407,7 +435,7 @@ export function SettingsView() {
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-96">
|
||||
{/* API Keys Section */}
|
||||
<div
|
||||
id="api-keys"
|
||||
@@ -1012,13 +1040,20 @@ export function SettingsView() {
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground">Theme</Label>
|
||||
<Label className="text-foreground">
|
||||
Theme{" "}
|
||||
{currentProject
|
||||
? `(for ${currentProject.name})`
|
||||
: "(Global)"}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Button
|
||||
variant={theme === "dark" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("dark")}
|
||||
variant={
|
||||
effectiveTheme === "dark" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("dark")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "dark"
|
||||
effectiveTheme === "dark"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1028,10 +1063,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Dark</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "light" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("light")}
|
||||
variant={
|
||||
effectiveTheme === "light" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("light")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "light"
|
||||
effectiveTheme === "light"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1041,10 +1078,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Light</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "retro" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("retro")}
|
||||
variant={
|
||||
effectiveTheme === "retro" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("retro")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "retro"
|
||||
effectiveTheme === "retro"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1054,10 +1093,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Retro</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "dracula" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("dracula")}
|
||||
variant={
|
||||
effectiveTheme === "dracula" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("dracula")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "dracula"
|
||||
effectiveTheme === "dracula"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1067,10 +1108,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Dracula</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "nord" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("nord")}
|
||||
variant={
|
||||
effectiveTheme === "nord" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("nord")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "nord"
|
||||
effectiveTheme === "nord"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1080,10 +1123,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Nord</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "monokai" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("monokai")}
|
||||
variant={
|
||||
effectiveTheme === "monokai" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("monokai")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "monokai"
|
||||
effectiveTheme === "monokai"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1093,10 +1138,14 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Monokai</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "tokyonight" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("tokyonight")}
|
||||
variant={
|
||||
effectiveTheme === "tokyonight"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("tokyonight")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "tokyonight"
|
||||
effectiveTheme === "tokyonight"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1106,10 +1155,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Tokyo Night</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "solarized" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("solarized")}
|
||||
variant={
|
||||
effectiveTheme === "solarized" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("solarized")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "solarized"
|
||||
effectiveTheme === "solarized"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1119,10 +1170,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Solarized</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "gruvbox" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("gruvbox")}
|
||||
variant={
|
||||
effectiveTheme === "gruvbox" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("gruvbox")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "gruvbox"
|
||||
effectiveTheme === "gruvbox"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1132,10 +1185,14 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Gruvbox</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "catppuccin" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("catppuccin")}
|
||||
variant={
|
||||
effectiveTheme === "catppuccin"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("catppuccin")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "catppuccin"
|
||||
effectiveTheme === "catppuccin"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1145,10 +1202,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">Catppuccin</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "onedark" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("onedark")}
|
||||
variant={
|
||||
effectiveTheme === "onedark" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("onedark")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "onedark"
|
||||
effectiveTheme === "onedark"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1158,10 +1217,12 @@ export function SettingsView() {
|
||||
<span className="font-medium text-sm">One Dark</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "synthwave" ? "secondary" : "outline"}
|
||||
onClick={() => setTheme("synthwave")}
|
||||
variant={
|
||||
effectiveTheme === "synthwave" ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => handleSetTheme("synthwave")}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
theme === "synthwave"
|
||||
effectiveTheme === "synthwave"
|
||||
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||
: ""
|
||||
}`}
|
||||
@@ -1307,10 +1368,11 @@ export function SettingsView() {
|
||||
Show profiles only by default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the Add Feature dialog will show only AI profiles
|
||||
and hide advanced model tweaking options (Claude SDK, thinking levels,
|
||||
and OpenAI Codex CLI). This creates a cleaner, less overwhelming UI.
|
||||
You can always disable this to access advanced settings.
|
||||
When enabled, the Add Feature dialog will show only AI
|
||||
profiles and hide advanced model tweaking options
|
||||
(Claude SDK, thinking levels, and OpenAI Codex CLI).
|
||||
This creates a cleaner, less overwhelming UI. You can
|
||||
always disable this to access advanced settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2 } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
|
||||
import type { SpecRegenerationEvent } from "@/types/electron";
|
||||
|
||||
export function SpecView() {
|
||||
@@ -299,13 +301,15 @@ export function SpecView() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleCreateSpec}
|
||||
disabled={!projectOverview.trim()}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showCreateDialog}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Generate Spec
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -359,12 +363,10 @@ export function SpecView() {
|
||||
{/* Editor */}
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<Card className="h-full overflow-hidden">
|
||||
<textarea
|
||||
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
|
||||
<XmlSyntaxEditor
|
||||
value={appSpec}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onChange={handleChange}
|
||||
placeholder="Write your app specification here..."
|
||||
spellCheck={false}
|
||||
data-testid="spec-editor"
|
||||
/>
|
||||
</Card>
|
||||
@@ -409,9 +411,11 @@ export function SpecView() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleRegenerate}
|
||||
disabled={!projectDefinition.trim() || isRegenerating}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showRegenerateDialog}
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<>
|
||||
@@ -424,7 +428,7 @@ export function SpecView() {
|
||||
Regenerate Spec
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -512,14 +513,16 @@ export function WelcomeView() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<HotkeyButton
|
||||
onClick={handleCreateProject}
|
||||
disabled={!newProjectName || !newProjectPath || isCreating}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showNewProjectDialog}
|
||||
data-testid="confirm-create-project"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -186,7 +186,7 @@ export function useAutoMode() {
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, maxConcurrency]);
|
||||
|
||||
// Stop auto mode
|
||||
// Stop auto mode - only turns off the toggle, running tasks continue
|
||||
const stop = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
console.error("No project selected");
|
||||
@@ -203,8 +203,11 @@ export function useAutoMode() {
|
||||
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
clearRunningTasks(currentProject.id);
|
||||
console.log("[AutoMode] Stopped successfully");
|
||||
// NOTE: We intentionally do NOT clear running tasks here.
|
||||
// Stopping auto mode only turns off the toggle to prevent new features
|
||||
// from being picked up. Running tasks will complete naturally and be
|
||||
// removed via the auto_mode_feature_complete event.
|
||||
console.log("[AutoMode] Stopped successfully - running tasks will continue");
|
||||
} else {
|
||||
console.error("[AutoMode] Failed to stop:", result.error);
|
||||
throw new Error(result.error || "Failed to stop auto mode");
|
||||
@@ -213,7 +216,7 @@ export function useAutoMode() {
|
||||
console.error("[AutoMode] Error stopping:", error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, clearRunningTasks]);
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
|
||||
// Stop a specific feature
|
||||
const stopFeature = useCallback(
|
||||
|
||||
269
backup.json
Normal file
269
backup.json
Normal file
@@ -0,0 +1,269 @@
|
||||
[
|
||||
{
|
||||
"id": "feature-1765387670653-bl83444lj",
|
||||
"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": "verified",
|
||||
"startedAt": "2025-12-10T17:42:09.158Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Fixed scrolling for file diffs in agent output modal. Changed approach: parent container (agent-output-modal.tsx) now handles scrolling with overflow-y-auto, while GitDiffPanel uses natural height without flex-based scrolling. Modified: agent-output-modal.tsx (line 304), git-diff-panel.tsx (lines 461, 500, 525, 614).",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765387746902-na752mp1y",
|
||||
"category": "Kanban",
|
||||
"description": "When the add feature modal pops up, make sure that the description is always the main focus. When it first loads up. Do not focus the prompt tab, which is currently doing this.",
|
||||
"steps": [],
|
||||
"status": "verified",
|
||||
"startedAt": "2025-12-10T17:29:13.854Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Added autoFocus prop to DescriptionImageDropZone component. Modified: description-image-dropzone.tsx (added autoFocus prop support), board-view.tsx (enabled autoFocus on add feature modal). Now the description textarea receives focus when the modal opens instead of the prompt tab.",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765388139100-ln31jgp5n",
|
||||
"category": "Uncategorized",
|
||||
"description": "Can you add a disclaimer .md file to this project saying that this uses a bunch of AI related tooling which could have access to your operating system and change and delete files and so use at your own risk. We tried to check it for security of vulnerability to make sure it's good. But you assume the risk and you should be reviewing the code yourself before you try to run it. And also sandboxing this so it doesn't have access to your whole operating system like using Docker to sandbox before you run it or use a virtual machine to sandbox it. and that we do not recommend running locally on your computer due to the risk of it having access to everything on your computer.\n\nUpdate or read me with a short paragraph overview/description at the top followed by a disclaimer section in red that points to the disclaimer file with the same disclaimer information.\n\nThen a section that lists out all the features of cool emojis.",
|
||||
"steps": [],
|
||||
"status": "verified",
|
||||
"startedAt": "2025-12-10T17:35:40.700Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Created DISCLAIMER.md with comprehensive security warnings about AI tooling risks and sandboxing recommendations. Updated README.md with project overview, red caution disclaimer section linking to DISCLAIMER.md, and features list with emojis covering all major functionality (Kanban, AI agents, multi-model support, etc.).",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765388388144-oa1dewze9",
|
||||
"category": "Uncategorized",
|
||||
"description": "Please fix the styling of the hotkeys to be more using the theme colors. Notice that they're kind of gray. I would rather than have some type of like light green if they're not active and then the brighter green if they are active and also the add feature but in the top right it's not very legible. So fix the accessibility of the hotkey but also keep it within the theme. You might just have to change the text inside of it to be bright green.",
|
||||
"steps": [],
|
||||
"status": "verified",
|
||||
"startedAt": "2025-12-10T17:40:02.745Z",
|
||||
"imagePaths": [
|
||||
{
|
||||
"id": "img-1765388352835-dgx4ishp0",
|
||||
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765388352832-6jnbgw8kg_Screenshot_2025-12-10_at_12.39.10_PM.png",
|
||||
"filename": "Screenshot 2025-12-10 at 12.39.10 PM.png",
|
||||
"mimeType": "image/png"
|
||||
},
|
||||
{
|
||||
"id": "img-1765388356955-a0gdovp5b",
|
||||
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765388356954-d59a65nf9_Screenshot_2025-12-10_at_12.39.15_PM.png",
|
||||
"filename": "Screenshot 2025-12-10 at 12.39.15 PM.png",
|
||||
"mimeType": "image/png"
|
||||
}
|
||||
],
|
||||
"skipTests": true,
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765388402095-x66aduwg3",
|
||||
"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",
|
||||
"startedAt": "2025-12-10T17:44:08.667Z",
|
||||
"imagePaths": [
|
||||
{
|
||||
"id": "img-1765388390408-eefybe95t",
|
||||
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765388390408-nn320yoyc_Screenshot_2025-12-10_at_12.39.47_PM.png",
|
||||
"filename": "Screenshot 2025-12-10 at 12.39.47 PM.png",
|
||||
"mimeType": "image/png"
|
||||
}
|
||||
],
|
||||
"skipTests": true,
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765388662444-as3hqn7be",
|
||||
"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",
|
||||
"startedAt": "2025-12-10T17:45:59.666Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Fixed hover cursor styling on all interactive elements. Modified: button.tsx (added cursor-pointer to base styles), dropdown-menu.tsx (added cursor-pointer to all menu items), checkbox.tsx (added cursor-pointer), tabs.tsx (added cursor-pointer to triggers), dialog.tsx (added cursor-pointer to close button), slider.tsx (added cursor-grab to thumb, cursor-pointer to track), globals.css (added global CSS rules for clickable elements to ensure consistent cursor behavior).",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765388693856-yx1dk1acj",
|
||||
"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",
|
||||
"startedAt": "2025-12-10T17:46:00.019Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Fixed tabs component theme integration. Modified: tabs.tsx. Changes: (1) Added visible border to TabsList container using theme's border color, (2) Changed inactive tab text to foreground/70 for better contrast, (3) Enhanced active tab with shadow-md and semi-transparent primary border, (4) Improved hover state with full accent background. Active tabs now properly use bg-primary/text-primary-foreground which adapts to each theme.",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765388754462-bek0flvkj",
|
||||
"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",
|
||||
"startedAt": "2025-12-10T17:47:20.170Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Fixed Kanban board flash/refresh issue. Changes: (1) board-view.tsx - Added isInitialLoadRef to only show loading spinner on initial load, not on feature reloads; memoized column features with useMemo to prevent recalculation on every render. (2) kanban-card.tsx - Wrapped with React.memo to prevent unnecessary re-renders. (3) kanban-column.tsx - Wrapped with React.memo for performance. The flash was caused by loadFeatures setting isLoading=true on every reload, which caused the entire board to unmount and show a loading spinner.",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765388793845-yhluf0sry",
|
||||
"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-10T18:00:33.814Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765389333728-y74hmz2yp",
|
||||
"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",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765389352488-j9bez5ztx",
|
||||
"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",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"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": "waiting_approval",
|
||||
"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": "backlog",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"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": "backlog",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"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": "backlog",
|
||||
"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,
|
||||
"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": "backlog",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"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": "in_progress",
|
||||
"startedAt": "2025-12-10T18:09:26.193Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Added search bar to Kanban board. Modified: board-view.tsx. Features: 1) Search input with icon at top of columns, 2) Case-insensitive filtering by description or category, 3) Clear button to reset search, 4) Real-time filtering as you type.",
|
||||
"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": "backlog",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"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": "backlog",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"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": "backlog",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user