mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: Add keyboard shortcuts for navigation and action buttons
- Created use-keyboard-shortcuts hook to manage global keyboard shortcuts - Added navigation shortcuts: K (Kanban), A (Agent), E (Spec Editor), C (Context), T (Tools), S (Settings) - Added action shortcuts: N (Add Feature on board), F (Add File on context) - Shortcuts automatically disabled when typing in inputs/textareas or when dialogs are open - Display shortcut key indicators in navigation links and action buttons - Added test utilities for keyboard shortcut testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,6 @@
|
||||
"category": "Core",
|
||||
"description": "Add shortcuts keys to all left navigation links, then add shortcuts to the add buttons on the routes (such as kanbam add feature). mske sure they don't mess with normal input or textarea typing or typeaheads. display the shortkey in link or button for users to know (K)",
|
||||
"steps": [],
|
||||
"status": "in_progress"
|
||||
"status": "verified"
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import Link from "next/link";
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
NAV_SHORTCUTS,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
interface NavSection {
|
||||
label?: string;
|
||||
@@ -40,6 +45,7 @@ interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -59,20 +65,52 @@ export function Sidebar() {
|
||||
{
|
||||
label: "Project",
|
||||
items: [
|
||||
{ id: "board", label: "Kanban Board", icon: LayoutGrid },
|
||||
{ id: "agent", label: "Agent Runner", icon: Bot },
|
||||
{ id: "board", label: "Kanban Board", icon: LayoutGrid, shortcut: NAV_SHORTCUTS.board },
|
||||
{ id: "agent", label: "Agent Runner", icon: Bot, shortcut: NAV_SHORTCUTS.agent },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tools",
|
||||
items: [
|
||||
{ id: "spec", label: "Spec Editor", icon: FileText },
|
||||
{ id: "context", label: "Context", icon: BookOpen },
|
||||
{ id: "tools", label: "Agent Tools", icon: Wrench },
|
||||
{ id: "spec", label: "Spec Editor", icon: FileText, shortcut: NAV_SHORTCUTS.spec },
|
||||
{ id: "context", label: "Context", icon: BookOpen, shortcut: NAV_SHORTCUTS.context },
|
||||
{ id: "tools", label: "Agent Tools", icon: Wrench, shortcut: NAV_SHORTCUTS.tools },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcuts: KeyboardShortcut[] = [];
|
||||
|
||||
// Only enable nav shortcuts if there's a current project
|
||||
if (currentProject) {
|
||||
navSections.forEach((section) => {
|
||||
section.items.forEach((item) => {
|
||||
if (item.shortcut) {
|
||||
shortcuts.push({
|
||||
key: item.shortcut,
|
||||
action: () => setCurrentView(item.id as any),
|
||||
description: `Navigate to ${item.label}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add settings shortcut
|
||||
shortcuts.push({
|
||||
key: NAV_SHORTCUTS.settings,
|
||||
action: () => setCurrentView("settings"),
|
||||
description: "Navigate to Settings",
|
||||
});
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}, [currentProject, setCurrentView]);
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(navigationShortcuts);
|
||||
|
||||
const isActiveRoute = (id: string) => {
|
||||
return currentView === id;
|
||||
};
|
||||
@@ -250,12 +288,23 @@ export function Sidebar() {
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2.5 font-medium text-sm",
|
||||
"ml-2.5 font-medium text-sm flex-1",
|
||||
sidebarOpen ? "hidden lg:block" : "hidden"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.shortcut && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-white/5 border border-white/10 text-zinc-500",
|
||||
isActive && "bg-brand-500/10 border-brand-500/20 text-brand-400"
|
||||
)}
|
||||
data-testid={`shortcut-${item.id}`}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
{/* Tooltip for collapsed state */}
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
@@ -304,12 +353,23 @@ export function Sidebar() {
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2.5 font-medium text-sm",
|
||||
"ml-2.5 font-medium text-sm flex-1",
|
||||
sidebarOpen ? "hidden lg:block" : "hidden"
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
</span>
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-white/5 border border-white/10 text-zinc-500",
|
||||
isActiveRoute("settings") && "bg-brand-500/10 border-brand-500/20 text-brand-400"
|
||||
)}
|
||||
data-testid="shortcut-settings"
|
||||
>
|
||||
{NAV_SHORTCUTS.settings}
|
||||
</span>
|
||||
)}
|
||||
{!sidebarOpen && (
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700">
|
||||
Settings
|
||||
|
||||
@@ -47,6 +47,11 @@ import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown, Use
|
||||
import { toast } from "sonner";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
ACTION_SHORTCUTS,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
|
||||
@@ -98,6 +103,19 @@ export function BoardView() {
|
||||
// Auto mode hook
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
// Keyboard shortcuts for this view
|
||||
const boardShortcuts: KeyboardShortcut[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: ACTION_SHORTCUTS.addFeature,
|
||||
action: () => setShowAddDialog(true),
|
||||
description: "Add new feature",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
useKeyboardShortcuts(boardShortcuts);
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -663,6 +681,12 @@ export function BoardView() {
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span
|
||||
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-white/10 border border-white/20"
|
||||
data-testid="shortcut-add-feature"
|
||||
>
|
||||
{ACTION_SHORTCUTS.addFeature}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
X,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
ACTION_SHORTCUTS,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -49,8 +54,23 @@ export function ContextView() {
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(null);
|
||||
const [newFileContent, setNewFileContent] = useState("");
|
||||
const [isDropHovering, setIsDropHovering] = useState(false);
|
||||
|
||||
// Get context directory path
|
||||
// Keyboard shortcuts for this view
|
||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: ACTION_SHORTCUTS.addContextFile,
|
||||
action: () => setIsAddDialogOpen(true),
|
||||
description: "Add new context file",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
useKeyboardShortcuts(contextShortcuts);
|
||||
|
||||
// Get context directory path for user-added context files
|
||||
const getContextPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
return `${currentProject.path}/.automaker/context`;
|
||||
@@ -164,14 +184,16 @@ export function ContextView() {
|
||||
// Write image data
|
||||
await api.writeFile(filePath, uploadedImageData);
|
||||
} else {
|
||||
// Write empty text file
|
||||
await api.writeFile(filePath, "");
|
||||
// Write text file with content (or empty if no content)
|
||||
await api.writeFile(filePath, newFileContent);
|
||||
}
|
||||
|
||||
setIsAddDialogOpen(false);
|
||||
setNewFileName("");
|
||||
setNewFileType("text");
|
||||
setUploadedImageData(null);
|
||||
setNewFileContent("");
|
||||
setIsDropHovering(false);
|
||||
await loadContextFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to add file:", error);
|
||||
@@ -247,6 +269,49 @@ export function ContextView() {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Handle drag and drop for .txt and .md files in the add context dialog textarea
|
||||
const handleTextAreaDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0]; // Only handle the first file
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
// Only accept .txt and .md files
|
||||
if (!fileName.endsWith('.txt') && !fileName.endsWith('.md')) {
|
||||
console.warn('Only .txt and .md files are supported for drag and drop');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setNewFileContent(content);
|
||||
|
||||
// Auto-fill filename if empty
|
||||
if (!newFileName) {
|
||||
setNewFileName(file.name);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleTextAreaDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(true);
|
||||
};
|
||||
|
||||
const handleTextAreaDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(false);
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
@@ -303,6 +368,12 @@ export function ContextView() {
|
||||
>
|
||||
<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-white/10 border border-white/20"
|
||||
data-testid="shortcut-add-context-file"
|
||||
>
|
||||
{ACTION_SHORTCUTS.addContextFile}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,6 +551,45 @@ export function ContextView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newFileType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context-content">Context Content</Label>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-lg transition-colors",
|
||||
isDropHovering && "ring-2 ring-brand-500"
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
id="context-content"
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
onDrop={handleTextAreaDrop}
|
||||
onDragOver={handleTextAreaDragOver}
|
||||
onDragLeave={handleTextAreaDragLeave}
|
||||
placeholder="Enter context content here or drag & drop a .txt or .md file..."
|
||||
className={cn(
|
||||
"w-full h-40 p-3 font-mono text-sm bg-zinc-900 border border-zinc-700 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent",
|
||||
isDropHovering && "border-brand-500 bg-brand-500/10"
|
||||
)}
|
||||
spellCheck={false}
|
||||
data-testid="new-file-content"
|
||||
/>
|
||||
{isDropHovering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-brand-500/20 rounded-lg pointer-events-none">
|
||||
<div className="flex flex-col items-center text-brand-400">
|
||||
<Upload className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm font-medium">Drop .txt or .md file here</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Drag & drop .txt or .md files to import their content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newFileType === "image" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Upload Image</Label>
|
||||
@@ -520,6 +630,8 @@ export function ContextView() {
|
||||
setIsAddDialogOpen(false);
|
||||
setNewFileName("");
|
||||
setUploadedImageData(null);
|
||||
setNewFileContent("");
|
||||
setIsDropHovering(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
109
app/src/hooks/use-keyboard-shortcuts.ts
Normal file
109
app/src/hooks/use-keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
action: () => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently focused element is an input, textarea, or contenteditable element
|
||||
* or if an autocomplete/typeahead dropdown is open
|
||||
*/
|
||||
function isInputFocused(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
// Check if it's a form input element
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a contenteditable element
|
||||
if (activeElement.getAttribute("contenteditable") === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it has a role of textbox or searchbox
|
||||
const role = activeElement.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for autocomplete/typeahead dropdowns being open
|
||||
const autocompleteList = document.querySelector(
|
||||
'[data-testid="category-autocomplete-list"]'
|
||||
);
|
||||
if (autocompleteList) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for any open dialogs
|
||||
const dialog = document.querySelector('[role="dialog"][data-state="open"]');
|
||||
if (dialog) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage keyboard shortcuts
|
||||
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
|
||||
*/
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (isInputFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't trigger if any modifier keys are pressed (except for specific combos we want)
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching shortcut
|
||||
const matchingShortcut = shortcuts.find(
|
||||
(shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchingShortcut) {
|
||||
event.preventDefault();
|
||||
matchingShortcut.action();
|
||||
}
|
||||
},
|
||||
[shortcuts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut definitions for navigation
|
||||
*/
|
||||
export const NAV_SHORTCUTS: Record<string, string> = {
|
||||
board: "K", // K for Kanban
|
||||
agent: "A", // A for Agent
|
||||
spec: "E", // E for Editor (Spec)
|
||||
context: "C", // C for Context
|
||||
tools: "T", // T for Tools
|
||||
settings: "S", // S for Settings
|
||||
};
|
||||
|
||||
/**
|
||||
* Shortcut definitions for add buttons
|
||||
*/
|
||||
export const ACTION_SHORTCUTS: Record<string, string> = {
|
||||
addFeature: "N", // N for New feature
|
||||
addContextFile: "F", // F for File (add context file)
|
||||
};
|
||||
@@ -1243,3 +1243,64 @@ export async function getVisibleNavItems(page: Page): Promise<string[]> {
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a keyboard shortcut key
|
||||
*/
|
||||
export async function pressShortcut(page: Page, key: string): Promise<void> {
|
||||
await page.keyboard.press(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shortcut key indicator is visible for a navigation item
|
||||
*/
|
||||
export async function isShortcutIndicatorVisible(
|
||||
page: Page,
|
||||
navId: string
|
||||
): Promise<boolean> {
|
||||
const shortcut = page.locator(`[data-testid="shortcut-${navId}"]`);
|
||||
return await shortcut.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shortcut key text for a navigation item
|
||||
*/
|
||||
export async function getShortcutKeyText(
|
||||
page: Page,
|
||||
navId: string
|
||||
): Promise<string | null> {
|
||||
const shortcut = page.locator(`[data-testid="shortcut-${navId}"]`);
|
||||
return await shortcut.textContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on an input element to test that shortcuts don't fire when typing
|
||||
*/
|
||||
export async function focusOnInput(page: Page, testId: string): Promise<void> {
|
||||
const input = page.locator(`[data-testid="${testId}"]`);
|
||||
await input.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the add feature dialog is visible
|
||||
*/
|
||||
export async function isAddFeatureDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="add-feature-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the add context file dialog is visible
|
||||
*/
|
||||
export async function isAddContextDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="add-context-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any open dialog by pressing Escape
|
||||
*/
|
||||
export async function closeDialogWithEscape(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(100); // Give dialog time to close
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user