diff --git a/apps/code/package.json b/apps/code/package.json index 188469e3e..de26dbe59 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -133,7 +133,7 @@ "@posthog/git": "workspace:*", "@posthog/hedgehog-mode": "^0.0.48", "@posthog/platform": "workspace:*", - "@posthog/quill": "0.1.0-alpha.7", + "@posthog/quill": "0.3.0-beta.1", "@posthog/shared": "workspace:*", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-icons": "^1.3.2", @@ -186,16 +186,17 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "reflect-metadata": "^0.2.2", - "semver": "^7.6.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "semver": "^7.6.0", "shadcn": "^4.1.2", "smol-toml": "^1.6.0", "sonner": "^2.0.7", "striptags": "^3.2.0", "tailwind-merge": "^3.5.0", + "tailwindcss-scroll-mask": "^0.0.3", "tippy.js": "^6.3.7", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", diff --git a/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx b/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx index 865cac623..c06f67b35 100644 --- a/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx +++ b/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx @@ -1,34 +1,27 @@ -import { KeyHint } from "@features/command/components/KeyHint"; -import { Code, Flex } from "@radix-ui/themes"; +import { Kbd, KbdGroup } from "@posthog/quill"; export function CommandKeyHints() { return ( - - - - - Navigate - - - - - - Select - - - - - - Close - - - +
+
+ + + + + navigate +
+
+ + + + select +
+
+ + Esc + + close +
+
); } diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/apps/code/src/renderer/features/command/components/CommandMenu.tsx index 48cbcde36..3fafe99f4 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/apps/code/src/renderer/features/command/components/CommandMenu.tsx @@ -1,9 +1,20 @@ import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore"; -import { Command } from "@features/command/components/Command"; import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; import { useFolders } from "@features/folders/hooks/useFolders"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; +import { + Autocomplete, + AutocompleteCollection, + AutocompleteGroup, + AutocompleteInput, + AutocompleteItem, + AutocompleteLabel, + AutocompleteList, + AutocompleteStatus, + Dialog, + DialogContent, +} from "@posthog/quill"; import { DesktopIcon, FileTextIcon, @@ -13,27 +24,37 @@ import { SunIcon, ViewVerticalIcon, } from "@radix-ui/react-icons"; -import { Flex, Text } from "@radix-ui/themes"; import { ANALYTICS_EVENTS, type CommandMenuAction, } from "@shared/types/analytics"; import { useNavigationStore } from "@stores/navigationStore"; -import { THEME_CYCLE_LABELS, useThemeStore } from "@stores/themeStore"; +import { useThemeStore } from "@stores/themeStore"; import { track } from "@utils/analytics"; -import { useCallback, useEffect, useRef } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; +import { useCallback, useEffect, useMemo, useState } from "react"; interface CommandMenuProps { open: boolean; onOpenChange: (open: boolean) => void; } +type Command = { + id: string; + label: string; + keywords?: string; + icon: React.ReactNode; + action: CommandMenuAction; + onRun: () => void; +}; + +type CommandSection = { label: string; items: Command[] }; + export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { navigateToTaskInput } = useNavigationStore(); const openSettingsDialog = useSettingsDialogStore((state) => state.open); + const closeSettingsDialog = useSettingsDialogStore((state) => state.close); const { folders } = useFolders(); - const { theme, cycleTheme } = useThemeStore(); + const { theme, setTheme } = useThemeStore(); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); const view = useNavigationStore((state) => state.view); const setReviewMode = useReviewNavigationStore( @@ -42,6 +63,19 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const getReviewMode = useReviewNavigationStore( (state) => state.getReviewMode, ); + const [query, setQuery] = useState(""); + const [systemPrefersDark, setSystemPrefersDark] = useState( + () => window.matchMedia("(prefers-color-scheme: dark)").matches, + ); + + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = (e: MediaQueryListEvent) => + setSystemPrefersDark(e.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + const openReviewPanel = useCallback(() => { const taskId = view.type === "task-detail" ? view.data?.id : undefined; if (!taskId) return; @@ -50,179 +84,209 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { setReviewMode(taskId, "split"); } }, [view, getReviewMode, setReviewMode]); - const commandRef = useRef(null); - - const close = useCallback(() => onOpenChange(false), [onOpenChange]); - - const trackAction = useCallback((action: CommandMenuAction) => { - track(ANALYTICS_EVENTS.COMMAND_MENU_ACTION, { action_type: action }); - }, []); - - const runAndClose = useCallback( - ( - fn: (...args: T) => void, - action?: CommandMenuAction, - ) => - (...args: T) => { - if (action) { - trackAction(action); - } - fn(...args); - close(); - }, - [close, trackAction], - ); useEffect(() => { if (open) { track(ANALYTICS_EVENTS.COMMAND_MENU_OPENED); + } else { + setQuery(""); } }, [open]); - useHotkeys("escape", close, { - enabled: open, - enableOnContentEditable: true, - enableOnFormTags: true, - preventDefault: true, - }); - - useHotkeys("mod+k", close, { - enabled: open, - enableOnContentEditable: true, - enableOnFormTags: true, - preventDefault: true, - }); - - useHotkeys("mod+p", close, { - enabled: open, - enableOnContentEditable: true, - enableOnFormTags: true, - preventDefault: true, - }); + const themeOptions = useMemo(() => { + const options: Command[] = []; + if (theme !== "light") { + options.push({ + id: "switch-theme-light", + label: "Switch to light mode", + keywords: "theme appearance", + icon: , + action: "toggle-theme", + onRun: () => setTheme("light"), + }); + } + if (theme !== "dark") { + options.push({ + id: "switch-theme-dark", + label: "Switch to dark mode", + keywords: "theme appearance", + icon: , + action: "toggle-theme", + onRun: () => setTheme("dark"), + }); + } + const systemMatchesCurrent = + (theme === "dark" && systemPrefersDark) || + (theme === "light" && !systemPrefersDark); + if (theme !== "system" && !systemMatchesCurrent) { + options.push({ + id: "switch-theme-system", + label: "Switch to system theme", + keywords: "theme appearance auto", + icon: , + action: "toggle-theme", + onRun: () => setTheme("system"), + }); + } + return options; + }, [theme, setTheme, systemPrefersDark]); - useEffect(() => { - if (!open) return; + const sections = useMemo(() => { + const navigation: Command[] = [ + { + id: "home", + label: "Home", + icon: , + action: "home", + onRun: () => { + closeSettingsDialog(); + navigateToTaskInput(); + }, + }, + { + id: "settings", + label: "Settings", + icon: , + action: "settings", + onRun: () => openSettingsDialog(), + }, + ]; + + const actions: Command[] = [ + ...themeOptions, + { + id: "toggle-left-sidebar", + label: "Toggle left sidebar", + icon: , + action: "toggle-left-sidebar", + onRun: toggleLeftSidebar, + }, + { + id: "open-review-panel", + label: "Open review panel", + icon: , + action: "open-review-panel", + onRun: openReviewPanel, + }, + { + id: "new-task", + label: "New task", + keywords: "create", + icon: , + action: "new-task", + onRun: () => { + closeSettingsDialog(); + navigateToTaskInput(); + }, + }, + ]; - const handleClickOutside = (event: MouseEvent) => { - if ( - commandRef.current && - !commandRef.current.contains(event.target as Node) - ) { - close(); - } - }; + const out: CommandSection[] = [ + { label: "Navigation", items: navigation }, + { label: "Actions", items: actions }, + ]; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [open, close]); + if (folders.length > 0) { + out.push({ + label: "New task in folder", + items: folders.map((folder) => ({ + id: `new-task-folder-${folder.id}`, + label: `New task in ${folder.name}`, + keywords: folder.path, + icon: , + action: "new-task", + onRun: () => { + closeSettingsDialog(); + navigateToTaskInput(folder.id); + }, + })), + }); + } - if (!open) return null; + return out; + }, [ + folders, + themeOptions, + navigateToTaskInput, + openSettingsDialog, + closeSettingsDialog, + toggleLeftSidebar, + openReviewPanel, + ]); + + const allCommands = useMemo( + () => sections.flatMap((s) => s.items), + [sections], + ); + + const handleSelect = (id: string | null): void => { + if (id === null) return; + const cmd = allCommands.find((c) => c.id === id); + if (!cmd) return; + track(ANALYTICS_EVENTS.COMMAND_MENU_ACTION, { action_type: cmd.action }); + cmd.onRun(); + onOpenChange(false); + setQuery(""); + }; return ( - -
+ - -
- -
- - - No results found. - - - - - Home - - openSettingsDialog(), "settings")} - > - - Settings - - - - - - {theme === "dark" && ( - - )} - {theme === "light" && ( - - )} - {theme === "system" && ( - - )} - {THEME_CYCLE_LABELS[theme]} - - - - Toggle left sidebar - - - - Open review panel - - - - New task - - - - {folders.length > 0 && ( - - {folders.map((folder) => ( - navigateToTaskInput(folder.id), - "new-task", - )} - > - - - New task in{" "} - {folder.name} - - - ))} - + + inline + defaultOpen + items={sections} + value={query} + autoHighlight="always" + onValueChange={(val, eventDetails) => { + if (eventDetails.reason !== "input-change") return; + if (typeof val === "string") { + setQuery(val); + } + }} + filter={(cmd, q) => { + if (!q) return true; + const haystack = `${cmd.label} ${cmd.keywords ?? ""}`.toLowerCase(); + return haystack.includes(q.toLowerCase()); + }} + > + + + No commands match "{query}" + + } + /> + + {(section: CommandSection) => ( + + {section.label} + + {(cmd: Command) => ( + handleSelect(cmd.id)} + > + {cmd.icon} + {cmd.label} + + )} + + )} - -
- + + -
-
+ + ); } diff --git a/apps/code/src/renderer/features/command/components/FilePicker.css b/apps/code/src/renderer/features/command/components/FilePicker.css deleted file mode 100644 index 648605def..000000000 --- a/apps/code/src/renderer/features/command/components/FilePicker.css +++ /dev/null @@ -1,112 +0,0 @@ -.file-picker-popover [cmdk-root] { - width: 640px; - min-width: 640px; - background: var(--color-panel-solid); - border-radius: var(--radius-3); - border: 1px solid var(--gray-6); - box-shadow: var(--shadow-6); - overflow: hidden; -} - -.file-picker-popover [cmdk-input] { - font-family: var(--default-font-family); - font-size: 13px; - padding: 12px 16px; - width: 100%; - background: transparent; - border: none; - outline: none; - color: var(--gray-12); - caret-color: var(--accent-9); -} - -.file-picker-popover [cmdk-input]::placeholder { - color: var(--gray-9); -} - -.file-picker-popover [cmdk-list] { - max-height: 400px; - overflow-y: auto; - overflow-x: hidden; - overscroll-behavior: contain; - transition: height 150ms ease; -} - -.file-picker-popover [cmdk-list]::-webkit-scrollbar { - width: 8px; -} - -.file-picker-popover [cmdk-list]::-webkit-scrollbar-track { - background: transparent; -} - -.file-picker-popover [cmdk-list]::-webkit-scrollbar-thumb { - background: var(--gray-6); - border-radius: 4px; -} - -.file-picker-popover [cmdk-list]::-webkit-scrollbar-thumb:hover { - background: var(--gray-7); -} - -.file-picker-popover [cmdk-group] { - padding: 4px 8px; -} - -.file-picker-popover [cmdk-group-heading] { - font-size: 12px; - font-weight: 500; - color: var(--gray-11); - padding: 8px 12px 4px; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.file-picker-popover [cmdk-item] { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border-radius: var(--radius-2); - cursor: pointer; - user-select: none; - transition: background-color 150ms ease; - color: var(--gray-12); -} - -.file-picker-popover [cmdk-item][data-selected="true"] { - background: var(--accent-3); - color: var(--accent-11); -} - -.file-picker-popover [cmdk-item][data-disabled="true"] { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -.file-picker-popover [cmdk-item]:active { - background: var(--accent-4); -} - -.file-picker-popover [cmdk-empty] { - display: flex; - align-items: center; - justify-content: center; - height: 64px; - font-size: 13px; - color: var(--gray-9); -} - -.file-picker-popover [cmdk-separator] { - height: 1px; - background: var(--gray-6); - margin: 4px 0; -} - -.rt-PopoverContent:has(.file-picker-popover) { - padding: 0; - background: transparent; - border: none; - box-shadow: none; -} diff --git a/apps/code/src/renderer/features/command/components/FilePicker.tsx b/apps/code/src/renderer/features/command/components/FilePicker.tsx index 25f755f35..c9da87672 100644 --- a/apps/code/src/renderer/features/command/components/FilePicker.tsx +++ b/apps/code/src/renderer/features/command/components/FilePicker.tsx @@ -1,10 +1,25 @@ import { FileIcon } from "@components/ui/FileIcon"; +import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { pathToFileItem, searchFiles, useRepoFiles } from "@hooks/useRepoFiles"; -import { Popover, Text } from "@radix-ui/themes"; +import { + type FileItem, + pathToFileItem, + searchFiles, + useRepoFiles, +} from "@hooks/useRepoFiles"; +import { + Autocomplete, + AutocompleteCollection, + AutocompleteGroup, + AutocompleteInput, + AutocompleteItem, + AutocompleteLabel, + AutocompleteList, + AutocompleteStatus, + Dialog, + DialogContent, +} from "@posthog/quill"; import { useCallback, useMemo, useState } from "react"; -import { Command } from "./Command"; -import "./FilePicker.css"; interface FilePickerProps { open: boolean; @@ -13,6 +28,12 @@ interface FilePickerProps { repoPath: string | undefined; } +type FileSection = { label?: string; items: FileItem[] }; + +// Cap the empty-query list to keep render cost bounded without virtualization. +// Typed queries are already capped upstream by fzf (MENTION_DISPLAY_LIMIT = 20). +const EMPTY_QUERY_LIMIT = 200; + export function FilePicker({ open, onOpenChange, @@ -28,84 +49,106 @@ export function FilePicker({ const handleOpenChange = useCallback( (isOpen: boolean) => { onOpenChange(isOpen); - if (!isOpen) { - setSearchQuery(""); - } + if (!isOpen) setSearchQuery(""); }, [onOpenChange], ); const { files: fileItems, fzf } = useRepoFiles(repoPath, open); - const displayedFiles = useMemo(() => { - if (!searchQuery.trim() && recentFiles.length > 0) { - return recentFiles.map(pathToFileItem); + const sections = useMemo(() => { + if (searchQuery.trim()) { + return [{ items: searchFiles(fzf, fileItems, searchQuery) }]; + } + if (recentFiles.length === 0) { + return [{ items: fileItems.slice(0, EMPTY_QUERY_LIMIT) }]; } - return searchFiles(fzf, fileItems, searchQuery); + // recentFiles is string[] of paths from panelLayoutStore, ordered most-recent-first. + const recentPathSet = new Set(recentFiles); + const recentItems = recentFiles.map(pathToFileItem); + const rest = fileItems + .filter((f) => !recentPathSet.has(f.path)) + .slice(0, Math.max(0, EMPTY_QUERY_LIMIT - recentItems.length)); + return [ + { label: "Recent", items: recentItems }, + { label: "Other files", items: rest }, + ]; }, [fzf, fileItems, searchQuery, recentFiles]); - const resultsKey = useMemo( - () => displayedFiles.map((f) => f.path).join(","), - [displayedFiles], - ); - const handleSelect = useCallback( - (filePath: string) => { - openFileInSplit(taskId, filePath, false); + (path: string) => { + openFileInSplit(taskId, path, false); handleOpenChange(false); }, [openFileInSplit, taskId, handleOpenChange], ); return ( - - -
- - handleOpenChange(false)} + + - - + inline + defaultOpen + items={sections} + filter={null} + value={searchQuery} + autoHighlight="always" + onValueChange={(val, eventDetails) => { + if (eventDetails.reason !== "input-change") return; + if (typeof val === "string") setSearchQuery(val); + }} + > + + + No files match "{searchQuery}" + + } /> - - - No files found. - - {displayedFiles.map((file) => ( - handleSelect(file.path)} + + {(section: FileSection, index: number) => ( + - - - {file.name} - - {file.dir && ( - - {file.dir} - + {section.label && ( + {section.label} )} - - ))} - - - - + + {(file: FileItem) => ( + handleSelect(file.path)} + className="block" + > + + {file.name} + {file.dir && ( + + {file.dir} + + )} + + )} + + + )} + + + + + ); } diff --git a/apps/code/src/renderer/features/command/components/KeyHint.tsx b/apps/code/src/renderer/features/command/components/KeyHint.tsx deleted file mode 100644 index d456e195c..000000000 --- a/apps/code/src/renderer/features/command/components/KeyHint.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Kbd } from "@radix-ui/themes"; - -interface KeyHintProps { - keys: string[]; - className?: string; -} - -export function KeyHint({ keys, className }: KeyHintProps) { - return ( -
- {keys.map((key) => ( - - {key} - - ))} -
- ); -} diff --git a/apps/code/src/renderer/features/editor/components/GithubRefChip.tsx b/apps/code/src/renderer/features/editor/components/GithubRefChip.tsx index cd2d96475..ccd97bb67 100644 --- a/apps/code/src/renderer/features/editor/components/GithubRefChip.tsx +++ b/apps/code/src/renderer/features/editor/components/GithubRefChip.tsx @@ -15,7 +15,6 @@ export function GithubRefChip({ return ( window.open(href, "_blank")} className="cli-file-mention mx-0.5 max-w-full cursor-pointer! whitespace-nowrap pl-1 align-middle active:translate-y-0" > diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx index a1673d0d5..3d87a65da 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx +++ b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx @@ -87,7 +87,6 @@ function DefaultChip({ const chipContent = ( window.open(id, "_blank") : undefined} className={`${chipBase} max-w-full whitespace-nowrap ${isGithubRef ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`} @@ -148,7 +147,6 @@ function PastedTextChip({ =20'} peerDependencies: - react: ^19.0.0 - react-dom: ^19.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 tailwindcss: ^4.0.0 '@posthog/rollup-plugin@1.4.0': @@ -10824,6 +10827,11 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwindcss-scroll-mask@0.0.3: + resolution: {integrity: sha512-vPuacFs5yHJRZ8MFP/qqKKRbnWlVDPAJ5VfRzOwrmtEJQ4pHm5K1xw/dXdF4v6Sx1QdbtCWJuGs9ucSTc+YanQ==} + peerDependencies: + tailwindcss: ^4 + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -15286,22 +15294,19 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@posthog/quill@0.1.0-alpha.7(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.2)': + '@posthog/quill@0.3.0-beta.1(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.2)': dependencies: '@base-ui/react': 1.3.0(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) class-variance-authority: 0.7.1 clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) lucide-react: 0.577.0(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) react-resizable-panels: 4.10.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: 2.6.1 tailwindcss: 4.2.2 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@types/react' - - '@types/react-dom' '@posthog/rollup-plugin@1.4.0(rollup@4.57.1)': dependencies: @@ -23493,6 +23498,10 @@ snapshots: tailwind-merge@3.5.0: {} + tailwindcss-scroll-mask@0.0.3(tailwindcss@4.2.2): + dependencies: + tailwindcss: 4.2.2 + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0