diff --git a/bun.lock b/bun.lock index 65f23eb..0059a87 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,9 @@ "@opencode-ai/sdk": "^1.2.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/electric-db-collection": "^0.2.33", + "@tanstack/hotkeys": "^0.4.1", "@tanstack/react-db": "0.1.70", + "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.162.8", "@tanstack/react-start": "^1.162.8", @@ -833,12 +835,16 @@ "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], + "@tanstack/hotkeys": ["@tanstack/hotkeys@0.4.1", "", { "dependencies": { "@tanstack/store": "^0.9.2" } }, "sha512-EGHqcdKP2jzy0dEkahA3ABtEXohMqPlU3Ac04sBQjgesJqr9xWuesJotOfWPh3P68kQQg8krNAtFTydIN3+WSw=="], + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.2.1", "", {}, "sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/react-db": ["@tanstack/react-db@0.1.70", "", { "dependencies": { "@tanstack/db": "0.5.26", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-OISznZtrjkbyktbAoj/2KyFf9Xv9vr7JfkQO4od4eH76X+vQXYZkVgs2IV5sM27YNatOyf1Or4BEELIbyYB4xA=="], + "@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.4.1", "", { "dependencies": { "@tanstack/hotkeys": "0.4.1", "@tanstack/react-store": "^0.9.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-hFh/kKQODn4kSytfIsEE/Vf1AaAb+NAFi4lx+OB49NmKY5z/BNH1/uEdYlVgOEvnDm4QrCISIMBOVpMgK5QNQg=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], "@tanstack/react-router": ["@tanstack/react-router@1.162.8", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.162.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-WunoknGI5ielJ833yl/F7Vq4nv/OWzrJVBsMgyxX16Db1DwVvX/B5zTg8EMjdZUOJ7ONpvur3t4aq7KQiYRagQ=="], @@ -849,7 +855,7 @@ "@tanstack/react-start-server": ["@tanstack/react-start-server@1.162.8", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-router": "1.162.8", "@tanstack/router-core": "1.162.6", "@tanstack/start-client-core": "1.162.6", "@tanstack/start-server-core": "1.162.6" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Zo9erJ37k7vOP0bAh37wJplRJ9oGT0SPUhYsKhlBjphT4w5racwIBlRRPeGuROWg1nii/Zd0/bxsHYQhU/v9lA=="], - "@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], + "@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="], "@tanstack/router-core": ["@tanstack/router-core@1.162.6", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-WFMNysDsDtnlM0G0L4LPWJuvpGatlPvBLGlPnieWYKem/Ed4mRHu7Hqw78MR/CMuFSRi9Gvv91/h8F3EVswAJw=="], @@ -2565,7 +2571,11 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], + "@tanstack/hotkeys/@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], + + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], + + "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], "@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], @@ -2843,6 +2853,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], + "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], diff --git a/package.json b/package.json index 84bd9b7..7862818 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "@opencode-ai/sdk": "^1.2.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/electric-db-collection": "^0.2.33", + "@tanstack/hotkeys": "^0.4.1", "@tanstack/react-db": "0.1.70", + "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.162.8", "@tanstack/react-start": "^1.162.8", diff --git a/src/components/new-task-button.tsx b/src/components/new-task-button.tsx index 57fc43a..d0427f5 100644 --- a/src/components/new-task-button.tsx +++ b/src/components/new-task-button.tsx @@ -1,10 +1,14 @@ import { useState, type ComponentProps } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useLiveQuery } from "@tanstack/react-db"; +import { useHotkey } from "@tanstack/react-hotkeys"; import { Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Kbd } from "@/components/ui/kbd"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { projectsCollection, tasksCollection } from "@/lib/collections"; import { createDesktopRunnerSession } from "@/lib/desktop-runner"; +import { hotkeys } from "@/lib/hotkeys"; type ButtonProps = ComponentProps; @@ -12,7 +16,7 @@ type NewTaskButtonProps = Omit iconOnly?: boolean; }; -export function NewTaskButton({ iconOnly = false, title, ...props }: NewTaskButtonProps) { +export function NewTaskButton({ iconOnly = false, ...props }: NewTaskButtonProps) { const navigate = useNavigate(); const [creating, setCreating] = useState(false); const { data: projects } = useLiveQuery((query) => @@ -21,6 +25,8 @@ export function NewTaskButton({ iconOnly = false, title, ...props }: NewTaskButt const [defaultProject] = projects; + useHotkey(hotkeys.newTask.keys, () => handleNewTask()); + function handleNewTask() { const repoUrl = defaultProject?.repo_url; if (creating || !defaultProject || !repoUrl) { @@ -66,20 +72,42 @@ export function NewTaskButton({ iconOnly = false, title, ...props }: NewTaskButt }); } - return ( + const icon = creating ? ( + + ) : ( + + ); + + const button = ( ); + + if (iconOnly) { + return ( + + {button} + + + {hotkeys.newTask.label} + + + + + ); + } + + return button; } diff --git a/src/components/task-page-header.tsx b/src/components/task-page-header.tsx index b546fbc..2a0124e 100644 --- a/src/components/task-page-header.tsx +++ b/src/components/task-page-header.tsx @@ -1,6 +1,9 @@ +import { useHotkey } from "@tanstack/react-hotkeys"; import { ExternalLink } from "lucide-react"; import { OpenEditorDropdown } from "@/components/open-editor-dropdown"; import { Button } from "@/components/ui/button"; +import { Kbd } from "@/components/ui/kbd"; +import { hotkeys } from "@/lib/hotkeys"; import { formatChecksProgress, getChecksStatusClasses, @@ -46,6 +49,9 @@ export function TaskPageHeader({ onError, onCreatePr, }: TaskPageHeaderProps) { + const canCreatePr = !!branchName && !pullRequest && !sending && !isRunning; + useHotkey(hotkeys.createPr.keys, () => onCreatePr(), { enabled: canCreatePr }); + return (
@@ -124,6 +130,7 @@ export function TaskPageHeader({ onClick={onCreatePr} > Create a PR +
) : null} diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx new file mode 100644 index 0000000..c5602f8 --- /dev/null +++ b/src/components/ui/kbd.tsx @@ -0,0 +1,18 @@ +import { formatKeys } from "@/lib/hotkeys"; +import { cn } from "@/lib/utils"; + +export function Kbd({ keys, className }: { keys: string; className?: string }) { + const parts = formatKeys(keys); + return ( + + {parts.map((part) => ( + + {part} + + ))} + + ); +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..e15f840 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; + +function TooltipProvider({ + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 6, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/src/lib/hotkeys.ts b/src/lib/hotkeys.ts new file mode 100644 index 0000000..0e72ac3 --- /dev/null +++ b/src/lib/hotkeys.ts @@ -0,0 +1,22 @@ +import { detectPlatform } from "@tanstack/hotkeys"; + +interface HotkeyDef { + keys: string; + label: string; +} + +export const hotkeys = { + newTask: { keys: "Mod+N", label: "New task" }, + createPr: { keys: "Mod+Shift+P", label: "Create PR" }, +} as const satisfies Record; + +const isMac = detectPlatform() === "mac"; + +export function formatKeys(keys: string): string[] { + return keys.split("+").map((part) => { + if (part === "Mod") return isMac ? "⌘" : "Ctrl"; + if (part === "Shift") return isMac ? "⇧" : "Shift"; + if (part === "Alt") return isMac ? "⌥" : "Alt"; + return part.toUpperCase(); + }); +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index e2052a2..a2ed160 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,7 +1,9 @@ /// import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; +import { HotkeysProvider } from "@tanstack/react-hotkeys"; import type { ReactNode } from "react"; import { ThemeProvider } from "@/components/theme-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { themeInitializationScript } from "@/lib/theme"; import appCss from "@/index.css?url"; @@ -31,7 +33,9 @@ function RootDocument({ children }: { children: ReactNode }) { - {children} + + {children} +