diff --git a/apps/code/package.json b/apps/code/package.json index d250aaaa1..be52c179f 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -130,7 +130,7 @@ "@posthog/electron-trpc": "workspace:*", "@posthog/git": "workspace:*", "@posthog/hedgehog-mode": "^0.0.48", - "@posthog/quill": "0.1.0-alpha.6", + "@posthog/quill": "0.1.0-alpha.7", "@posthog/shared": "workspace:*", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-icons": "^1.3.2", diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index a3deb63a1..cdfa8da1a 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -1,4 +1,5 @@ import { DiffStatsBadge } from "@features/code-review/components/DiffStatsBadge"; +import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; import { CloudGitInteractionHeader } from "@features/git-interaction/components/CloudGitInteractionHeader"; import { GitInteractionHeader } from "@features/git-interaction/components/GitInteractionHeader"; import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger"; @@ -26,6 +27,7 @@ export function HeaderRow() { const activeTaskId = view.type === "task-detail" ? view.data?.id : undefined; const activeWorkspace = useWorkspace(activeTaskId); const isCloudTask = activeWorkspace?.mode === "cloud"; + const showTaskSection = view.type === "task-detail"; const handleLeftSidebarMouseDown = (e: React.MouseEvent) => { e.preventDefault(); @@ -94,24 +96,46 @@ export function HeaderRow() { )} - {view.type === "task-detail" && view.data && ( + {showTaskSection && view.type === "task-detail" && view.data && ( -
- {isCloudTask ? ( - - ) : ( - + {activeWorkspace && + (activeWorkspace.branchName || activeWorkspace.baseBranch) && ( +
+ +
)} -
+ + {isCloudTask ? ( + + ) : ( + + )}
)} diff --git a/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx b/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx index 1edef5fbd..ffbdfe794 100644 --- a/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx +++ b/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx @@ -1,5 +1,12 @@ import { DotsThree } from "@phosphor-icons/react"; -import { DropdownMenu, IconButton, Text } from "@radix-ui/themes"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@posthog/quill"; import { useDiffViewerStore } from "@renderer/features/code-editor/stores/diffViewerStore"; export function DiffSettingsMenu() { @@ -19,42 +26,38 @@ export function DiffSettingsMenu() { ); return ( - - - - - - - - - - {wordWrap ? "Disable word wrap" : "Enable word wrap"} - - - - - {wordDiffs ? "Disable word diffs" : "Enable word diffs"} - - - - - {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} - - - - - - {showReviewComments - ? "Hide review comments" - : "Show review comments"} - - - - + + + + + } + /> + + + {wordWrap ? "Disable word wrap" : "Enable word wrap"} + + + {wordDiffs ? "Disable word diffs" : "Enable word diffs"} + + + {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} + + + + {showReviewComments ? "Hide review comments" : "Show review comments"} + + + ); } diff --git a/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx b/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx index bbd37b15b..23e156c64 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx +++ b/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx @@ -434,7 +434,7 @@ export function ReviewShell({ theme: { dark: "github-dark", light: "github-light" }, }} > - + {children} @@ -491,7 +492,7 @@ function FileHeaderRow({ fontFamily: "var(--code-font-family)", fontSize: "12px", cursor: "pointer", - userSelect: "none", + // userSelect: "none", width: "100%", background: "none", border: "none", diff --git a/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx b/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx index 4da3b8b7b..94758cbcf 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx +++ b/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx @@ -1,20 +1,14 @@ import { Tooltip } from "@components/ui/Tooltip"; import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { - ArrowsClockwise, - ArrowsIn, - ArrowsOut, - Columns, - CornersIn, - CornersOut, - Rows, -} from "@phosphor-icons/react"; -import { Flex, IconButton, Separator, Text } from "@radix-ui/themes"; +import { ArrowsClockwise, Columns, Rows, X } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Flex, Separator, Text } from "@radix-ui/themes"; import { DiffSettingsMenu } from "@renderer/features/code-review/components/DiffSettingsMenu"; import { type ReviewMode, useReviewNavigationStore, } from "@renderer/features/code-review/stores/reviewNavigationStore"; +import { FoldVertical, Maximize, Minimize, UnfoldVertical } from "lucide-react"; import { memo } from "react"; interface ReviewToolbarProps { @@ -31,8 +25,6 @@ interface ReviewToolbarProps { export const ReviewToolbar = memo(function ReviewToolbar({ taskId, fileCount, - linesAdded, - linesRemoved, allExpanded, onExpandAll, onCollapseAll, @@ -50,16 +42,21 @@ export const ReviewToolbar = memo(function ReviewToolbar({ setReviewMode(taskId, next); }; + const handleClose = () => { + setReviewMode(taskId, "closed"); + }; + return ( {fileCount} file{fileCount !== 1 ? "s" : ""} changed - - {linesAdded > 0 && ( - +{linesAdded} - )} - {linesRemoved > 0 && ( - -{linesRemoved} - )} - - + {onRefresh && ( - + )} - - {viewMode === "split" ? : } - - - - {allExpanded ? : } - - - + + + - + + + - {reviewMode === "expanded" ? ( - + ) : ( - + )} - + + + + + + + + + diff --git a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx b/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx index 1a669db7f..763acfcf9 100644 --- a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx +++ b/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx @@ -1,27 +1,37 @@ -import { Combobox } from "@components/ui/combobox/Combobox"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { HardDrives, Plus } from "@phosphor-icons/react"; -import { Flex, Tooltip } from "@radix-ui/themes"; +import { CaretDown, HardDrives, Plus } from "@phosphor-icons/react"; +import { + Button, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxListFooter, + ComboboxTrigger, +} from "@posthog/quill"; import { useTRPC } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; interface EnvironmentSelectorProps { repoPath: string | null; value: string | null; onChange: (environmentId: string | null) => void; disabled?: boolean; - variant?: "outline" | "ghost"; } +const NONE_VALUE = "__none__"; + export function EnvironmentSelector({ repoPath, value, onChange, disabled = false, - variant = "outline", }: EnvironmentSelectorProps) { const [open, setOpen] = useState(false); + const anchorRef = useRef(null); const trpc = useTRPC(); const { data: environments = [] } = useQuery({ @@ -38,9 +48,7 @@ export function EnvironmentSelector({ const selectedEnvironment = environments.find((env) => env.id === value); const displayText = selectedEnvironment?.name ?? "No environment"; - const NONE_VALUE = "__none__"; - - const handleChange = (newValue: string) => { + const handleChange = (newValue: string | null) => { onChange(newValue === NONE_VALUE ? null : newValue || null); setOpen(false); }; @@ -52,64 +60,99 @@ export function EnvironmentSelector({ .open("environments", { repoPath: repoPath ?? undefined }); }; - const triggerContent = ( - - - {displayText} - - ); + const isDisabled = disabled || !repoPath; + + const CREATE_ENV_ACTION = "__create_env__"; + const allItems = [ + NONE_VALUE, + ...environments.map((env) => env.id), + CREATE_ENV_ACTION, + ]; return ( - - handleChange(v as string | null)} + open={open} + onOpenChange={setOpen} + disabled={isDisabled} + > +
+ + + {displayText} + + + } + /> +
+ - - {triggerContent} - + + No environments found. - - - No environments found. - - - - No environment - - {environments.map((env) => ( - + {(itemValue: string) => { + if (itemValue === CREATE_ENV_ACTION) { + return ( + + + + Create local environment + + + ); + } + if (itemValue === NONE_VALUE) { + return ( + + No environment + + ); + } + const env = environments.find((e) => e.id === itemValue); + if (!env) return null; + return ( + } + title={env.name} + className="relative" > {env.name} - - ))} - - - - - - -
-
+ + ); + }} + + + ); } diff --git a/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx b/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx index b17c913a3..106d02528 100644 --- a/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx +++ b/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx @@ -1,26 +1,32 @@ import { useFolders } from "@features/folders/hooks/useFolders"; import { + CaretDown, Folder as FolderIcon, FolderOpen, - GitBranchIcon, + GitBranch, } from "@phosphor-icons/react"; -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; -import type { Responsive } from "@radix-ui/themes/dist/esm/props/prop-def.js"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + MenuLabel, +} from "@posthog/quill"; import { trpcClient } from "@renderer/trpc"; interface FolderPickerProps { value: string; onChange: (path: string) => void; placeholder?: string; - size?: Responsive<"1" | "2">; + size?: "1" | "2"; } export function FolderPicker({ value, onChange, placeholder = "Select folder...", - size = "1", }: FolderPickerProps) { const { getRecentFolders, @@ -49,98 +55,60 @@ export function FolderPicker({ } }; - // If no folders, render as a plain button that directly opens file picker if (recentFolders.length === 0) { return ( - ); } return ( - - - - - - + + + + {displayValue || placeholder} + + + + } + /> + - - Recent - + Recent {recentFolders.map((folder) => ( - handleSelect(folder.path)} + onClick={() => handleSelect(folder.path)} > - - - - {folder.name} - - - + + {folder.name} + ))} - + - - - - Open folder... - - - - + + + Open folder... + + + ); } diff --git a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx index dadbc3189..5a800dc0f 100644 --- a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx +++ b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx @@ -1,7 +1,14 @@ -import { Combobox } from "@components/ui/combobox/Combobox"; import { GithubLogo } from "@phosphor-icons/react"; -import { Button, Flex, Text } from "@radix-ui/themes"; -import { useState } from "react"; +import { + Button, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "@posthog/quill"; interface GitHubRepoPickerProps { value: string | null; @@ -19,69 +26,59 @@ export function GitHubRepoPicker({ repositories, isLoading, placeholder = "Select repository...", - size = "1", disabled = false, }: GitHubRepoPickerProps) { - const [open, setOpen] = useState(false); - if (isLoading) { return ( - ); } if (repositories.length === 0) { return ( - ); } return ( - { + if (v) onChange(v as string); + }} disabled={disabled} > - - - - - {value ?? placeholder} - - - - - {({ filtered, hasMore, moreCount }) => ( - <> - - No repositories found. - {filtered.map((repo) => ( - - {repo} - - ))} - {hasMore && ( -
- {moreCount} more {moreCount === 1 ? "repo" : "repos"} — type to - filter -
- )} - - )} -
-
+ + + {value ?? placeholder} + + } + /> + + + No repositories found. + + {(repo: string) => ( + + {repo} + + )} + + + ); } diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index ad9e10619..1dbdb87f0 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -1,13 +1,22 @@ -import { Combobox } from "@components/ui/combobox/Combobox"; import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { GitBranch, Plus } from "@phosphor-icons/react"; -import { Flex, Spinner, Tooltip } from "@radix-ui/themes"; +import { CaretDown, GitBranch, Plus, Spinner } from "@phosphor-icons/react"; +import { + Button, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxListFooter, + ComboboxTrigger, +} from "@posthog/quill"; import { useTRPC } from "@renderer/trpc"; import { toast } from "@renderer/utils/toast"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; interface BranchSelectorProps { repoPath: string | null; @@ -33,7 +42,6 @@ export function BranchSelector({ defaultBranch, disabled, loading, - variant = "outline", workspaceMode, selectedBranch, onBranchSelect, @@ -45,6 +53,7 @@ export function BranchSelector({ taskId, }: BranchSelectorProps) { const [open, setOpen] = useState(false); + const anchorRef = useRef(null); const trpc = useTRPC(); const { actions } = useGitInteractionStore(); @@ -65,7 +74,18 @@ export function BranchSelector({ ), ); - const branches = isCloudMode ? (cloudBranches ?? []) : localBranches; + // TODO: remove mock branches + const MOCK_BRANCHES = Array.from( + { length: 50 }, + (_, i) => + `feature/branch-${i + 1}-${["auth-refactor", "fix-login", "add-analytics", "upgrade-deps", "redesign-sidebar", "perf-optimization", "migration-v2", "hotfix-crash", "experiment-ai", "cleanup-tests"][i % 10]}`, + ); + const branches = [ + ...(isCloudMode ? (cloudBranches ?? []) : localBranches), + ...MOCK_BRANCHES, + ]; + const CREATE_BRANCH_ACTION = "__create_branch__"; + const allItems = isCloudMode ? branches : [...branches, CREATE_BRANCH_ACTION]; const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading); const cloudStillLoading = isCloudMode && cloudBranchesLoading && branches.length === 0; @@ -85,7 +105,8 @@ export function BranchSelector({ }), ); - const handleBranchChange = (value: string) => { + const handleBranchChange = (value: string | null) => { + if (!value) return; if (isSelectionOnly) { onBranchSelect?.(value || null); } else if (value && value !== currentBranch) { @@ -95,9 +116,6 @@ export function BranchSelector({ }); } if (isCloudMode && value) { - // User committed to a branch — pause the background pagination. If they - // later re-open the picker, `onCloudPickerOpen` will resume it from - // wherever the cached pages left off. onCloudBranchCommit?.(); } setOpen(false); @@ -114,113 +132,93 @@ export function BranchSelector({ ? "Loading..." : (displayedBranch ?? "No branch"); - // Show the spinner on the trigger while the first page is still loading. - // Once we have branches to show, any "loading more" background work is - // surfaced inside the open picker instead, so the trigger goes back to its - // normal branch icon. const showSpinner = effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore); - const triggerContent = ( - - {showSpinner ? ( - - ) : ( - - )} - {displayText} - - ); + const isDisabled = !!(disabled || !repoPath || cloudStillLoading); return ( - - handleBranchChange(v as string | null)} + open={open} + onOpenChange={(nextOpen) => handleOpenChange(nextOpen)} + disabled={isDisabled} + > + + {showSpinner ? ( + + ) : ( + + )} + {displayText} + + + } + /> + - - {triggerContent} - - - - {({ filtered, hasMore, moreCount }) => ( - <> - - {isCloudMode && cloudBranchesFetchingMore && ( - - - Loading more ({branches.length})… - - )} - No branches found. - - {filtered.length > 0 && ( - + + {isCloudMode && cloudBranchesFetchingMore && ( +
+ + Loading more ({branches.length})… +
+ )} + + No branches found. + + + {(item: string) => + item === CREATE_BRANCH_ACTION ? ( + + { + setOpen(false); + actions.openBranch( + taskId + ? getSuggestedBranchName(taskId, repoPath ?? undefined) + : undefined, + ); + }} > - {filtered.map((branch) => ( - } - > - {branch} - - ))} - {hasMore && ( -
- {moreCount} more {moreCount === 1 ? "branch" : "branches"}{" "} - — type to filter -
- )} -
- )} - - {!isCloudMode && ( - - - - )} - - )} -
-
-
+ + Create new branch + + + ) : ( + + {item} + + ) + } + + + ); } diff --git a/apps/code/src/renderer/features/message-editor/README.md b/apps/code/src/renderer/features/message-editor/README.md index 7dfe28048..c252739e1 100644 --- a/apps/code/src/renderer/features/message-editor/README.md +++ b/apps/code/src/renderer/features/message-editor/README.md @@ -7,8 +7,10 @@ Tiptap-based editor with mention support for files (`@`) and commands (`/`). ``` message-editor/ ├── components/ -│ ├── MessageEditor.tsx # Main editor component -│ └── EditorToolbar.tsx # Attachment buttons +│ ├── PromptInput.tsx # Shared prompt input (editor + Quill InputGroup toolbar) +│ ├── ModeSelector.tsx # Mode dropdown (plan / acceptEdits / default / etc.) +│ ├── AttachmentMenu.tsx # File + issue picker +│ └── AttachmentsBar.tsx # Attached-files strip shown above the editor ├── tiptap/ │ ├── useTiptapEditor.ts # Hook that creates the editor │ ├── useDraftSync.ts # Persists drafts to store @@ -23,12 +25,12 @@ message-editor/ │ └── draftStore.ts # Zustand store for drafts ├── utils/ │ └── content.ts # EditorContent type + serialization -└── types.ts # Suggestion item types +└── types.ts # EditorHandle + suggestion item types ``` ## How it works -1. `MessageEditor` calls `useTiptapEditor` with session config +1. `PromptInput` calls `useTiptapEditor` with session config 2. `useTiptapEditor` creates a Tiptap editor with extensions from `extensions.ts` 3. Extensions include `CommandMention` and `FileMention` which show suggestions on `/` and `@` 4. Suggestions are fetched via `getSuggestions.ts` (commands from session store, files via tRPC) diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.css b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.css deleted file mode 100644 index 7932cfab8..000000000 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.css +++ /dev/null @@ -1,161 +0,0 @@ -.attachment-menu { - padding: var(--space-1); - min-width: 140px; -} - -.attachment-menu-item { - all: unset; - box-sizing: border-box; - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - height: var(--space-5); - padding: 0 var(--space-2); - font-size: 13px; - line-height: var(--line-height-1); - letter-spacing: var(--letter-spacing-1); - color: var(--gray-12); - border-radius: var(--radius-1); - cursor: pointer; - user-select: none; - white-space: nowrap; -} - -.attachment-menu-item:hover { - background: var(--accent-a4); -} - -.attachment-menu-item:disabled { - opacity: 0.5; - cursor: default; - pointer-events: none; -} - -.attachment-menu-item-icon { - flex-shrink: 0; - display: flex; - align-items: center; - color: var(--gray-11); -} - -.issue-picker [cmdk-root] { - display: flex; - flex-direction: column; - max-height: 340px; - width: 300px; - overflow: hidden; -} - -.issue-picker .combobox-input-wrapper { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-1) var(--space-2); - flex-shrink: 0; - background: var(--gray-2); - border-bottom: 1px solid var(--gray-a6); -} - -.issue-picker .combobox-input-icon { - color: var(--gray-9); - flex-shrink: 0; -} - -.issue-picker [cmdk-input] { - font-family: var(--default-font-family); - padding: 0; - width: 100%; - background: transparent; - border: none; - outline: none; - color: var(--gray-12); - caret-color: var(--accent-9); - font-size: 13px; - line-height: var(--line-height-1); - flex: 1; -} - -.issue-picker [cmdk-input]::placeholder { - color: var(--gray-9); -} - -.issue-picker [cmdk-list] { - flex: 1; - min-height: 230px; - overflow-y: auto; - overflow-x: hidden; - overscroll-behavior: contain; - scrollbar-width: thin; - scrollbar-color: var(--gray-a5) transparent; -} - -.issue-picker [cmdk-group] { - padding: var(--space-1); -} - -.issue-picker [cmdk-item] { - display: flex; - padding: var(--space-1) var(--space-2); - cursor: pointer; - user-select: none; - color: var(--gray-12); - outline: none; - border-radius: var(--radius-1); - font-size: 13px; - line-height: var(--line-height-1); - letter-spacing: var(--letter-spacing-1); - min-width: 0; - overflow: hidden; -} - -.issue-picker [cmdk-item][data-selected="true"] { - background: var(--accent-a4); - color: var(--accent-12); -} - -.issue-picker [cmdk-item]:active { - background: var(--accent-4); -} - -.issue-picker [cmdk-empty] { - display: flex; - align-items: center; - justify-content: center; - height: 64px; - font-size: 13px; - color: var(--gray-9); -} - -.issue-picker-text { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - overflow: hidden; -} - -.issue-picker-title { - display: flex; - align-items: center; - gap: var(--space-1); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; -} - -.issue-picker-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.issue-picker-meta { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--gray-a10); - font-size: 12px; -} diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx index 739c4b9d7..8b996cfc6 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx @@ -1,10 +1,51 @@ import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockSelectFiles = vi.hoisted(() => vi.fn()); +vi.mock("@posthog/quill", () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + DropdownMenuTrigger: ({ render }: { render: React.ReactElement }) => render, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + onClick, + disabled, + title, + }: React.ButtonHTMLAttributes) => ( + + ), + Combobox: ({ children }: { children: React.ReactNode }) => <>{children}, + ComboboxContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ComboboxEmpty: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ComboboxInput: () => , + ComboboxItem: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ComboboxList: () => null, +})); + vi.mock("@renderer/trpc/client", () => ({ trpcClient: { os: { @@ -18,6 +59,9 @@ vi.mock("@renderer/trpc/client", () => ({ getGhStatus: { queryOptions: () => ({}), }, + searchGithubIssues: { + queryOptions: () => ({}), + }, }, }), })); @@ -54,8 +98,7 @@ describe("AttachmentMenu", () => { , ); - await user.click(screen.getByRole("button")); - await user.click(await screen.findByText("Add file")); + await user.click(screen.getByText("Add file")); expect(mockSelectFiles).toHaveBeenCalledOnce(); expect(onAddAttachment).toHaveBeenCalledWith({ diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index d9b4454cc..4c54daa29 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -1,18 +1,20 @@ -import "./AttachmentMenu.css"; import { File, GithubLogo, Paperclip } from "@phosphor-icons/react"; -import { IconButton, Popover } from "@radix-ui/themes"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { useQuery } from "@tanstack/react-query"; import { getFilePath } from "@utils/getFilePath"; -import { getFileName } from "@utils/path"; import { useRef, useState } from "react"; import type { FileAttachment, MentionChip } from "../utils/content"; import { persistBrowserFile } from "../utils/persistFile"; import { IssuePicker } from "./IssuePicker"; -type View = "menu" | "issues"; - interface AttachmentMenuProps { disabled?: boolean; repoPath?: string | null; @@ -44,9 +46,10 @@ export function AttachmentMenu({ iconSize = 14, attachTooltip = "Attach", }: AttachmentMenuProps) { - const [open, setOpen] = useState(false); - const [view, setView] = useState("menu"); + const [menuOpen, setMenuOpen] = useState(false); + const [issuePickerOpen, setIssuePickerOpen] = useState(false); const fileInputRef = useRef(null); + const paperclipRef = useRef(null); const trpc = useTRPC(); const { data: ghStatus } = useQuery( @@ -95,21 +98,17 @@ export function AttachmentMenu({ } }; - const handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen); - if (!isOpen) { - setView("menu"); - } - }; - const handleAddFile = async () => { - setOpen(false); + setMenuOpen(false); try { const filePaths = await trpcClient.os.selectFiles.query(); if (filePaths.length > 0) { for (const filePath of filePaths) { - onAddAttachment({ id: filePath, label: getFileName(filePath) }); + onAddAttachment({ + id: filePath, + label: filePath.split("/").pop() ?? filePath, + }); } } return; @@ -120,26 +119,16 @@ export function AttachmentMenu({ fileInputRef.current?.click(); }; + const handleOpenIssuePicker = () => { + setMenuOpen(false); + setIssuePickerOpen(true); + }; + const handleIssueSelect = (chip: MentionChip) => { onInsertChip(chip); - setOpen(false); - setView("menu"); + setIssuePickerOpen(false); }; - const issueButton = ( - - ); - return ( <> - - - + + + + } + /> + + + + Add file + + - - - - - {view === "menu" ? ( -
- - {issueDisabledReason ? ( - {issueButton} - ) : ( - issueButton - )} -
- ) : ( -
- -
- )} -
-
+ + Add issue + + + + ); } diff --git a/apps/code/src/renderer/features/message-editor/components/EditorToolbar.tsx b/apps/code/src/renderer/features/message-editor/components/EditorToolbar.tsx deleted file mode 100644 index 2f5efb8eb..000000000 --- a/apps/code/src/renderer/features/message-editor/components/EditorToolbar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ModelSelector } from "@features/sessions/components/ModelSelector"; -import { Flex } from "@radix-ui/themes"; -import type { FileAttachment, MentionChip } from "../utils/content"; -import { AttachmentMenu } from "./AttachmentMenu"; - -interface EditorToolbarProps { - disabled?: boolean; - taskId?: string; - adapter?: "claude" | "codex"; - repoPath?: string | null; - onAddAttachment: (attachment: FileAttachment) => void; - onAttachFiles?: (files: File[]) => void; - onInsertChip: (chip: MentionChip) => void; - attachTooltip?: string; - iconSize?: number; - hideSelectors?: boolean; -} - -export function EditorToolbar({ - disabled = false, - taskId, - adapter, - repoPath, - onAddAttachment, - onAttachFiles, - onInsertChip, - attachTooltip = "Attach", - iconSize = 14, - hideSelectors = false, -}: EditorToolbarProps) { - return ( - - - {!hideSelectors && ( - - )} - - ); -} diff --git a/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx b/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx index a805a3108..6f3bfe6bc 100644 --- a/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx +++ b/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx @@ -1,109 +1,141 @@ -import { MagnifyingGlass } from "@phosphor-icons/react"; -import { Spinner } from "@radix-ui/themes"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + Item, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@posthog/quill"; import { useTRPC } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; -import { Command } from "cmdk"; import { useEffect, useRef, useState } from "react"; import type { MentionChip } from "../utils/content"; interface IssuePickerProps { repoPath: string; + open: boolean; + onOpenChange: (open: boolean) => void; onSelect: (chip: MentionChip) => void; + anchor: React.RefObject; } -export function IssuePicker({ repoPath, onSelect }: IssuePickerProps) { +type Issue = { + number: number; + title: string; + url: string; + repo: string; + state: string; + labels: string[]; +}; + +export function IssuePicker({ + repoPath, + open, + onOpenChange, + onSelect, + anchor, +}: IssuePickerProps) { const trpc = useTRPC(); - const [inputValue, setInputValue] = useState(""); + const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const timerRef = useRef | null>(null); useEffect(() => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { - setDebouncedQuery(inputValue); + setDebouncedQuery(query); }, 300); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; - }, [inputValue]); + }, [query]); + + useEffect(() => { + if (!open) { + setQuery(""); + setDebouncedQuery(""); + } + }, [open]); - const { data: issues = [], isLoading } = useQuery( + const { data: issues = [] } = useQuery( trpc.git.searchGithubIssues.queryOptions( { directoryPath: repoPath, query: debouncedQuery || undefined, limit: 25, }, - { staleTime: 30_000 }, + { staleTime: 30_000, enabled: open && !!repoPath }, ), ); - const handleSelect = (issue: (typeof issues)[number]) => { + const handleValueChange = (value: Issue | null) => { + if (!value) return; onSelect({ type: "github_issue", - id: issue.url, - label: `#${issue.number} - ${issue.title}`, + id: value.url, + label: `#${value.number} - ${value.title}`, }); }; return ( - -
- - + items={issues as Issue[]} + open={open} + onOpenChange={(nextOpen) => onOpenChange(nextOpen)} + inputValue={query} + onInputValueChange={(value) => setQuery(value ?? "")} + onValueChange={(value) => handleValueChange(value as Issue | null)} + filter={null} + > + + -
- - - {isLoading ? ( -
- -
- ) : issues.length === 0 ? ( - No issues found. - ) : ( - - {issues.map((issue) => ( - handleSelect(issue)} - > -
- - + No issues found. + + {(issue: Issue) => ( + + + + + + + #{issue.number} - {issue.title} - - + + {issue.repo} {issue.labels.length > 0 && ` · ${issue.labels.join(", ")}`} - -
-
- ))} -
- )} -
-
+ + + + + )} + + + ); } diff --git a/apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx deleted file mode 100644 index a6a9a7634..000000000 --- a/apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import "./message-editor.css"; -import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; -import { getUserPromptsForTask } from "@features/sessions/stores/sessionStore"; -import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; -import { useConnectivity } from "@hooks/useConnectivity"; -import { ArrowUp, Stop } from "@phosphor-icons/react"; -import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; -import { EditorContent } from "@tiptap/react"; -import { hasOpenOverlay } from "@utils/overlay"; -import { forwardRef, useCallback, useEffect, useImperativeHandle } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { useDraftStore } from "../stores/draftStore"; -import { useTiptapEditor } from "../tiptap/useTiptapEditor"; -import type { EditorHandle } from "../types"; -import { AttachmentsBar } from "./AttachmentsBar"; -import { EditorToolbar } from "./EditorToolbar"; -import { ModeIndicatorInput } from "./ModeIndicatorInput"; - -export type { EditorHandle as MessageEditorHandle }; - -interface ModeAndBranchRowProps { - modeOption?: SessionConfigOption; - onModeChange?: () => void; - repoPath?: string | null; - cloudBranch?: string | null; - disabled?: boolean; - isBashMode?: boolean; - isCloud?: boolean; - taskId?: string; -} - -function ModeAndBranchRow({ - modeOption, - onModeChange, - repoPath, - cloudBranch, - disabled, - isBashMode, - isCloud, - taskId, -}: ModeAndBranchRowProps) { - const { currentBranch: gitBranch } = useGitQueries(repoPath ?? undefined); - const currentBranch = cloudBranch ?? gitBranch; - - const showModeIndicator = !!onModeChange; - const showBranchSelector = !!currentBranch; - - if (!showModeIndicator && !showBranchSelector && !isBashMode) { - return null; - } - - return ( - - - {isBashMode ? ( - - ! bash mode - - ) : ( - <> - {showModeIndicator && modeOption && ( - - )} - {showModeIndicator && !modeOption && ( - - Loading... - - )} - - )} - - - {showBranchSelector && ( - - - - )} - - - ); -} - -interface MessageEditorProps { - sessionId: string; - placeholder?: string; - onSubmit?: (text: string) => void; - onBashCommand?: (command: string) => void; - onBashModeChange?: (isBashMode: boolean) => void; - onCancel?: () => void; - onAttachFiles?: (files: File[]) => void; - autoFocus?: boolean; - modeOption?: SessionConfigOption; - onModeChange?: () => void; - onFocus?: () => void; - onBlur?: () => void; - isActiveSession?: boolean; -} - -export const MessageEditor = forwardRef( - ( - { - sessionId, - placeholder = "Type a message... @ to mention files, ! for bash mode, / for skills", - onSubmit, - onBashCommand, - onBashModeChange, - onCancel, - onAttachFiles, - autoFocus = false, - modeOption, - onModeChange, - onFocus, - onBlur, - isActiveSession = true, - }, - ref, - ) => { - const context = useDraftStore((s) => s.contexts[sessionId]); - const focusRequested = useDraftStore((s) => s.focusRequested[sessionId]); - const clearFocusRequest = useDraftStore((s) => s.actions.clearFocusRequest); - const { isOnline } = useConnectivity(); - const taskId = context?.taskId; - const isCloud = useIsWorkspaceCloudRun(taskId); - const disabled = context?.disabled ?? false; - const isLoading = context?.isLoading ?? false; - const repoPath = context?.repoPath; - const cloudBranch = context?.cloudBranch; - const isSubmitDisabled = disabled || !isOnline; - - const getPromptHistory = useCallback( - () => getUserPromptsForTask(taskId), - [taskId], - ); - - const { - editor, - isReady, - isEmpty, - isBashMode, - submit, - focus, - blur, - clear, - getText, - getContent, - setContent, - insertChip, - attachments, - addAttachment, - removeAttachment, - } = useTiptapEditor({ - sessionId, - taskId, - placeholder, - disabled, - submitDisabled: !isOnline, - isLoading, - autoFocus, - context: { taskId, repoPath }, - getPromptHistory, - capabilities: { bashMode: !isCloud }, - onSubmit, - onBashCommand, - onBashModeChange, - onFocus, - onBlur, - }); - - useImperativeHandle( - ref, - () => ({ - focus, - blur, - clear, - isEmpty: () => isEmpty, - getContent, - getText, - setContent, - insertChip, - addAttachment, - removeAttachment, - }), - [ - focus, - blur, - clear, - isEmpty, - getContent, - getText, - setContent, - insertChip, - addAttachment, - removeAttachment, - ], - ); - - useEffect(() => { - if (!focusRequested || !isReady) return; - focus(); - clearFocusRequest(sessionId); - }, [focusRequested, focus, clearFocusRequest, sessionId, isReady]); - - useHotkeys( - "escape", - (e) => { - if (hasOpenOverlay()) return; - if (!isActiveSession) return; - if (isLoading && onCancel) { - e.preventDefault(); - onCancel(); - } - }, - { - enableOnFormTags: true, - enableOnContentEditable: true, - enabled: isLoading && !!onCancel, - }, - [isActiveSession, isLoading, onCancel], - ); - - const handleContainerClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if (!target.closest("button") && !target.closest(".ProseMirror")) { - focus(); - } - }; - - return ( - - - -
- -
- - - - - - - {isLoading && onCancel ? ( - - - - - - ) : ( - - { - e.stopPropagation(); - submit(); - }} - disabled={isSubmitDisabled || isEmpty} - loading={isLoading} - style={{ - backgroundColor: - isSubmitDisabled || isEmpty - ? "var(--accent-a4)" - : undefined, - color: - isSubmitDisabled || isEmpty - ? "var(--accent-8)" - : undefined, - }} - > - - - - )} - - - -
- ); - }, -); - -MessageEditor.displayName = "MessageEditor"; diff --git a/apps/code/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx b/apps/code/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx deleted file mode 100644 index aa5e0b847..000000000 --- a/apps/code/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import type { - SessionConfigOption, - SessionConfigSelectGroup, - SessionConfigSelectOption, - SessionConfigSelectOptions, -} from "@agentclientprotocol/sdk"; -import { - Circle, - Eye, - LockOpen, - Pause, - Pencil, - ShieldCheck, -} from "@phosphor-icons/react"; -import { Flex, Text } from "@radix-ui/themes"; - -interface ModeStyle { - icon: React.ReactNode; - colorVar: string; -} - -const MODE_STYLES: Record = { - plan: { - icon: , - colorVar: "var(--amber-11)", - }, - default: { - icon: , - colorVar: "var(--gray-11)", - }, - acceptEdits: { - icon: , - colorVar: "var(--green-11)", - }, - bypassPermissions: { - icon: , - colorVar: "var(--red-11)", - }, - auto: { - icon: , - colorVar: "var(--gray-11)", - }, - "read-only": { - icon: , - colorVar: "var(--amber-11)", - }, - "full-access": { - icon: , - colorVar: "var(--red-11)", - }, -}; - -const DEFAULT_STYLE: ModeStyle = { - icon: , - colorVar: "var(--gray-11)", -}; - -interface ModeIndicatorInputProps { - modeOption: SessionConfigOption | undefined; - onCycleMode?: () => void; -} - -function flattenOptions( - options: SessionConfigSelectOptions, -): SessionConfigSelectOption[] { - if (options.length === 0) return []; - if ("group" in options[0]) { - return (options as SessionConfigSelectGroup[]).flatMap( - (group) => group.options, - ); - } - return options as SessionConfigSelectOption[]; -} - -export function ModeIndicatorInput({ - modeOption, - onCycleMode, -}: ModeIndicatorInputProps) { - if (!modeOption || modeOption.type !== "select") return null; - - const id = modeOption.currentValue; - - const style = MODE_STYLES[id] ?? DEFAULT_STYLE; - const option = flattenOptions(modeOption.options).find( - (opt) => opt.value === id, - ); - const label = option?.name ?? id; - - return ( - - - - {style.icon} - {label} - - - (shift+tab to cycle) - - - - ); -} diff --git a/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx b/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx new file mode 100644 index 000000000..7d150c3d6 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx @@ -0,0 +1,138 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { + CaretDown, + Circle, + Eye, + LockOpen, + Pause, + Pencil, + ShieldCheck, +} from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + MenuLabel, +} from "@posthog/quill"; +import { flattenSelectOptions } from "@renderer/features/sessions/stores/sessionStore"; + +interface ModeStyle { + icon: React.ReactNode; + className: string; +} + +const MODE_STYLES: Record = { + plan: { + icon: , + className: "text-amber-11", + }, + default: { + icon: , + className: "text-gray-11", + }, + acceptEdits: { + icon: , + className: "text-green-11", + }, + bypassPermissions: { + icon: , + className: "text-red-11", + }, + auto: { + icon: , + className: "text-gray-11", + }, + "read-only": { + icon: , + className: "text-amber-11", + }, + "full-access": { + icon: , + className: "text-red-11", + }, +}; + +const DEFAULT_STYLE: ModeStyle = { + icon: , + className: "text-gray-11", +}; + +function getStyle(value: string): ModeStyle { + return MODE_STYLES[value] ?? DEFAULT_STYLE; +} + +interface ModeSelectorProps { + modeOption: SessionConfigOption | undefined; + onChange: (value: string) => void; + allowBypassPermissions: boolean; + disabled?: boolean; +} + +export function ModeSelector({ + modeOption, + onChange, + allowBypassPermissions, + disabled, +}: ModeSelectorProps) { + if (!modeOption || modeOption.type !== "select") return null; + + const allOptions = flattenSelectOptions(modeOption.options); + const options = allowBypassPermissions + ? allOptions + : allOptions.filter( + (opt) => + opt.value !== "bypassPermissions" && opt.value !== "full-access", + ); + if (options.length === 0) return null; + + const currentValue = modeOption.currentValue; + const currentStyle = getStyle(currentValue); + const currentLabel = + options.find((opt) => opt.value === currentValue)?.name ?? currentValue; + + return ( + + + {currentStyle.icon} + {currentLabel} + + + } + /> + + Mode + + {options.map((option) => { + const style = getStyle(option.value); + return ( + + {style.icon} + {option.name} + + ); + })} + + + + ); +} diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx new file mode 100644 index 000000000..e3c829e79 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -0,0 +1,309 @@ +import "./message-editor.css"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { TourHighlight } from "@components/TourHighlight"; +import { ArrowUp, Stop } from "@phosphor-icons/react"; +import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { EditorContent } from "@tiptap/react"; +import { hasOpenOverlay } from "@utils/overlay"; +import { forwardRef, useCallback, useEffect, useImperativeHandle } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useDraftStore } from "../stores/draftStore"; +import { useTiptapEditor } from "../tiptap/useTiptapEditor"; +import type { EditorHandle } from "../types"; +import { AttachmentMenu } from "./AttachmentMenu"; +import { AttachmentsBar } from "./AttachmentsBar"; +import { ModeSelector } from "./ModeSelector"; + +export type { EditorHandle }; + +export interface PromptInputProps { + sessionId: string; + placeholder?: string; + // editor state + disabled?: boolean; + isLoading?: boolean; + autoFocus?: boolean; + isActiveSession?: boolean; + submitDisabledExternal?: boolean; + clearOnSubmit?: boolean; + // session context + taskId?: string; + repoPath?: string | null; + // mode + modeOption?: SessionConfigOption; + onModeChange?: (value: string) => void; + allowBypassPermissions?: boolean; + // capabilities + enableBashMode?: boolean; + enableCommands?: boolean; + // toolbar slots + modelSelector?: React.ReactElement | null | false; + reasoningSelector?: React.ReactElement | null | false; + // tour hook for the send button (new-task flow) + tourHighlightSubmit?: boolean; + // prompt history provider + getPromptHistory?: () => string[]; + // callbacks + onSubmit?: (text: string) => void; + onBashCommand?: (command: string) => void; + onBashModeChange?: (isBashMode: boolean) => void; + onCancel?: () => void; + onAttachFiles?: (files: File[]) => void; + onEmptyChange?: (isEmpty: boolean) => void; + onFocus?: () => void; + onBlur?: () => void; + // manual submit override (for flows like new-task that submit outside the editor hook) + onSubmitClick?: () => void; + submitTooltipOverride?: string; +} + +export const PromptInput = forwardRef( + ( + { + sessionId, + placeholder = "Type a message...", + disabled = false, + isLoading = false, + autoFocus = false, + isActiveSession = true, + submitDisabledExternal = false, + clearOnSubmit, + taskId, + repoPath, + modeOption, + onModeChange, + allowBypassPermissions = false, + enableBashMode = false, + enableCommands = true, + modelSelector, + reasoningSelector, + tourHighlightSubmit = false, + getPromptHistory, + onSubmit, + onBashCommand, + onBashModeChange, + onCancel, + onAttachFiles, + onEmptyChange, + onFocus, + onBlur, + onSubmitClick, + submitTooltipOverride, + }, + ref, + ) => { + const focusRequested = useDraftStore((s) => s.focusRequested[sessionId]); + const clearFocusRequest = useDraftStore((s) => s.actions.clearFocusRequest); + + const { + editor, + isReady, + isEmpty, + isBashMode, + submit, + focus, + blur, + clear, + getText, + getContent, + setContent, + insertChip, + attachments, + addAttachment, + removeAttachment, + } = useTiptapEditor({ + sessionId, + taskId, + placeholder, + disabled, + submitDisabled: submitDisabledExternal, + isLoading, + autoFocus, + clearOnSubmit, + context: { taskId, repoPath: repoPath ?? undefined }, + capabilities: { + bashMode: enableBashMode, + commands: enableCommands, + }, + getPromptHistory, + onSubmit, + onBashCommand, + onBashModeChange, + onEmptyChange, + onFocus, + onBlur, + }); + + useImperativeHandle( + ref, + () => ({ + focus, + blur, + clear, + isEmpty: () => isEmpty, + getContent, + getText, + setContent, + insertChip, + addAttachment, + removeAttachment, + }), + [ + focus, + blur, + clear, + isEmpty, + getContent, + getText, + setContent, + insertChip, + addAttachment, + removeAttachment, + ], + ); + + useEffect(() => { + if (!focusRequested || !isReady) return; + focus(); + clearFocusRequest(sessionId); + }, [focusRequested, focus, clearFocusRequest, sessionId, isReady]); + + useHotkeys( + "escape", + (e) => { + if (hasOpenOverlay()) return; + if (!isActiveSession) return; + if (isLoading && onCancel) { + e.preventDefault(); + onCancel(); + } + }, + { + enableOnFormTags: true, + enableOnContentEditable: true, + enabled: isLoading && !!onCancel, + }, + [isActiveSession, isLoading, onCancel], + ); + + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if ( + !target.closest("button") && + !target.closest('[role="menu"]') && + !target.closest(".ProseMirror") + ) { + focus(); + } + }, + [focus], + ); + + const handleSubmitClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onSubmitClick) { + onSubmitClick(); + } else { + submit(); + } + }; + + const submitBlocked = submitDisabledExternal || isEmpty; + const submitTooltip = + submitTooltipOverride ?? + (submitBlocked ? "Enter a message" : "Send message"); + + // Render send/stop button (wrapped in TourHighlight when requested) + const submitButton = + isLoading && onCancel ? ( + + + + + + ) : ( + + + + + + ); + + const wrappedSubmit = tourHighlightSubmit ? ( + {submitButton} + ) : ( + submitButton + ); + + return ( + + + {attachments.length > 0 && ( + + + + )} +
+ +
+ + + {modeOption && onModeChange && ( + + )} + {modelSelector && {modelSelector}} + {reasoningSelector && {reasoningSelector}} + {isBashMode && ( + + ! bash + + )} + {wrappedSubmit} + +
+
+ ); + }, +); + +PromptInput.displayName = "PromptInput"; diff --git a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx index 40f1796f9..166d0865a 100644 --- a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx @@ -2,15 +2,14 @@ import { TourHighlight } from "@components/TourHighlight"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; -import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; -import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; +import { PromptInput } from "@features/message-editor/components/PromptInput"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; +import type { EditorHandle } from "@features/message-editor/types"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { - cycleModeOption, - getCurrentModeFromConfigOptions, -} from "@features/sessions/stores/sessionStore"; -import { TaskInputEditor } from "@features/task-detail/components/TaskInputEditor"; +import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; +import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; +import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; +import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { WorkspaceModeSelect } from "@features/task-detail/components/WorkspaceModeSelect"; import { usePreviewConfig } from "@features/task-detail/hooks/usePreviewConfig"; import { useTaskCreation } from "@features/task-detail/hooks/useTaskCreation"; @@ -28,7 +27,6 @@ import { useRef, useState, } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTutorialTour } from "../hooks/useTutorialTour"; import { TutorialHedgehog } from "./TutorialHedgehog"; @@ -42,7 +40,7 @@ const HEDGEHOG_MESSAGES: Record = { "select-model": "Now pick your AI model — try selecting Claude Opus 4.7 for the most capable option!", "explain-mode": - "Press Shift+Tab to cycle through execution modes like Plan, Code, and more.", + "Open the mode menu in the prompt input to switch between Plan, Code, and other execution modes.", "auto-fill-prompt": "I've written your first task prompt — it'll set up PostHog based on the signals you enabled. Press Next when you're ready!", "submit-task": @@ -58,6 +56,7 @@ interface TutorialStepProps { } export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { + const { allowBypassPermissions } = useSettingsStore(); const completeOnboarding = useOnboardingStore( (state) => state.completeOnboarding, ); @@ -72,7 +71,7 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { hasNextButton, } = useTutorialTour(); - const editorRef = useRef(null); + const editorRef = useRef(null); // Clear any leftover draft and delay content until the hedgehog has animated in const [contentVisible, setContentVisible] = useState(false); @@ -200,26 +199,25 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { [subStep, advance], ); - // Shift+tab mode cycling (only active during explain-mode step) - const handleCycleMode = useCallback(() => { - const nextValue = cycleModeOption(modeOption); - if (nextValue && modeOption) { - setConfigOption(modeOption.id, nextValue); - } - }, [modeOption, setConfigOption]); - - useHotkeys( - "shift+tab", - (e) => { - e.preventDefault(); - handleCycleMode(); + const handleModeChange = useCallback( + (value: string) => { + if (modeOption) { + setConfigOption(modeOption.id, value); + } + if (subStep === "explain-mode") { + advance(); + } }, - { - enableOnFormTags: true, - enableOnContentEditable: true, - enabled: !!modeOption && subStep === "explain-mode", + [modeOption, setConfigOption, subStep, advance], + ); + + const handleReasoningChange = useCallback( + (value: string) => { + if (thoughtOption) { + setConfigOption(thoughtOption.id, value); + } }, - [handleCycleMode, modeOption, subStep], + [thoughtOption, setConfigOption], ); // Submit and complete onboarding @@ -369,12 +367,13 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) {
- {/* Row 2: Editor — opaque when a child (model/submit) is the active highlight */} + {/* Row 2: Prompt input — editor + toolbar + mode dropdown */} - { - setConfigOption(configId, value); - if (configId === modelOption?.id) { - handleModelChange(value); - } - }} - onAdapterChange={() => {}} + placeholder="What do you want to ship?" + disabled={isCreatingTask || subStep === "navigating"} isLoading={isPreviewLoading} - autoFocus={false} - tourHighlight={ - isHighlighted("model-selector") - ? "model-selector" - : isHighlighted("submit-button") - ? "submit-button" - : null + submitDisabledExternal={ + !isEnabled("submit-button") || !canSubmit || isCreatingTask } - /> - - - - {/* Row 3: Mode indicator */} - -
- + {}} + onModelChange={handleModelChange} + disabled={isCreatingTask} + isConnecting={isPreviewLoading} + /> + } + reasoningSelector={ + !isPreviewLoading && ( + + ) + } + tourHighlightSubmit={isHighlighted("submit-button")} + onEmptyChange={setEditorIsEmpty} + onSubmitClick={handleTutorialSubmit} + onSubmit={() => { + if (canSubmit) handleTutorialSubmit(); + }} />
diff --git a/apps/code/src/renderer/features/panels/components/TabbedPanel.tsx b/apps/code/src/renderer/features/panels/components/TabbedPanel.tsx index 6fca60903..63813f45d 100644 --- a/apps/code/src/renderer/features/panels/components/TabbedPanel.tsx +++ b/apps/code/src/renderer/features/panels/components/TabbedPanel.tsx @@ -152,10 +152,16 @@ export const TabbedPanel: React.FC = ({ }, [content.activeTabId, content.tabs]); return ( - + {content.showTabs !== false && ( diff --git a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx b/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx index 862d79127..dfb3eeebe 100644 --- a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx @@ -1,5 +1,15 @@ import type { SessionConfigSelectGroup } from "@agentclientprotocol/sdk"; -import { Select, Text } from "@radix-ui/themes"; +import { CaretDown } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + MenuLabel, +} from "@posthog/quill"; import { Fragment, useMemo } from "react"; import { getSessionService } from "../service/service"; import { @@ -19,7 +29,6 @@ export function ModelSelector({ taskId, disabled, onModelChange, - adapter: _adapter, }: ModelSelectorProps) { const session = useSessionForTask(taskId); const modelOption = useModelConfigOptionForTask(taskId); @@ -55,46 +64,61 @@ export function ModelSelector({ options.find((opt) => opt.value === currentValue)?.name ?? currentValue; return ( - - + + {currentLabel} + + + } + /> + - {currentLabel} - - - {groupedOptions.length > 0 - ? groupedOptions.map((group, index) => ( + {groupedOptions.length > 0 ? ( + + {groupedOptions.map((group, index) => ( - {index > 0 && } - - {group.name} - {group.options.map((model) => ( - - {model.name} - - ))} - + {index > 0 && } + {group.name} + {group.options.map((model) => ( + + {model.name} + + ))} - )) - : options.map((model) => ( - - {model.name} - ))} - - + + ) : ( + + {options.map((model) => ( + + {model.name} + + ))} + + )} + + ); } diff --git a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx b/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx index 9270d9dee..bf89b1f8c 100644 --- a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx @@ -1,6 +1,14 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { Brain, CaretDown, Check } from "@phosphor-icons/react"; -import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; +import { Brain, CaretDown } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + MenuLabel, +} from "@posthog/quill"; import { flattenSelectOptions } from "../stores/sessionStore"; interface ReasoningLevelSelectorProps { @@ -10,16 +18,6 @@ interface ReasoningLevelSelectorProps { disabled?: boolean; } -const triggerStyle = { - fontSize: "var(--font-size-1)", - color: "var(--gray-11)", - padding: "4px 8px", - height: "auto", - minHeight: "unset", - gap: "6px", - userSelect: "none" as const, -}; - export function ReasoningLevelSelector({ thoughtOption, adapter, @@ -35,47 +33,47 @@ export function ReasoningLevelSelector({ const activeLevel = thoughtOption.currentValue; const activeLabel = options.find((opt) => opt.value === activeLevel)?.name ?? activeLevel; + const triggerLabel = `${adapter === "codex" ? "Reasoning" : "Effort"}: ${activeLabel}`; return ( - - - - - - - {options.map((level) => ( - onChange?.(level.value)} + + - - - {level.name} - - - ))} - - + + {triggerLabel} + + + } + /> + + {adapter === "codex" ? "Reasoning" : "Effort"} + onChange?.(value)} + > + {options.map((level) => ( + + {level.name} + + ))} + + + ); } diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 2e3559173..d5c06f78a 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -1,16 +1,17 @@ import { isOtherOption } from "@components/action-selector/constants"; import { PermissionSelector } from "@components/permissions/PermissionSelector"; import { - MessageEditor, - type MessageEditorHandle, -} from "@features/message-editor/components/MessageEditor"; + PromptInput, + type EditorHandle as PromptInputHandle, +} from "@features/message-editor/components/PromptInput"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; import { - cycleModeOption, useModeConfigOptionForTask, usePendingPermissionsForTask, } from "@features/sessions/stores/sessionStore"; import type { Plan } from "@features/sessions/types"; +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { Pause, Spinner, Warning } from "@phosphor-icons/react"; import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes"; @@ -21,7 +22,6 @@ import { } from "@shared/types/session-events"; import { getFilePath } from "@utils/getFilePath"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { getSessionService } from "../service/service"; import { useSessionViewActions, @@ -29,6 +29,7 @@ import { } from "../stores/sessionViewStore"; import { ConversationView } from "./ConversationView"; import { DropZoneOverlay } from "./DropZoneOverlay"; +import { ModelSelector } from "./ModelSelector"; import { PlanStatusBar } from "./PlanStatusBar"; import { RawLogsView } from "./raw-logs/RawLogsView"; @@ -91,18 +92,33 @@ export function SessionView({ const { setShowRawLogs } = useSessionViewActions(); const pendingPermissions = usePendingPermissionsForTask(taskId); const modeOption = useModeConfigOptionForTask(taskId); + const { allowBypassPermissions } = useSettingsStore(); + const currentModeId = modeOption?.currentValue; - const handleModeChange = useCallback(() => { - if (!taskId) return; - const nextMode = cycleModeOption(modeOption); - if (nextMode) { + useEffect(() => { + if (allowBypassPermissions) return; + const isBypass = + currentModeId === "bypassPermissions" || currentModeId === "full-access"; + if (isBypass && taskId) { getSessionService().setSessionConfigOptionByCategory( taskId, "mode", - nextMode, + "default", ); } - }, [taskId, modeOption]); + }, [allowBypassPermissions, currentModeId, taskId]); + + const handleModeChange = useCallback( + (nextMode: string) => { + if (!taskId) return; + getSessionService().setSessionConfigOptionByCategory( + taskId, + "mode", + nextMode, + ); + }, + [taskId], + ); const sessionId = taskId ?? "default"; const setContext = useDraftStore((s) => s.actions.setContext); @@ -126,27 +142,7 @@ export function SessionView({ isPromptPending, ]); - useHotkeys( - "shift+tab", - (e) => { - e.preventDefault(); - if (!taskId) return; - const nextMode = cycleModeOption(modeOption); - if (nextMode) { - getSessionService().setSessionConfigOptionByCategory( - taskId, - "mode", - nextMode, - ); - } - }, - { - enableOnFormTags: true, - enableOnContentEditable: true, - enabled: isRunning && !!modeOption && isActiveSession, - }, - [taskId, isRunning, modeOption, isActiveSession], - ); + const isCloudRun = useIsWorkspaceCloudRun(taskId); const latestPlan = useMemo((): Plan | null => { let planIndex = -1; @@ -195,7 +191,7 @@ export function SessionView({ ); const [isDraggingFile, setIsDraggingFile] = useState(false); - const editorRef = useRef(null); + const editorRef = useRef(null); const dragCounterRef = useRef(0); const firstPendingPermission = useMemo(() => { @@ -347,7 +343,7 @@ export function SessionView({ @@ -445,7 +441,7 @@ export function SessionView({ justify="center" direction="column" gap="2" - className="absolute inset-0 bg-gray-1" + className="absolute inset-0 bg-background" > {errorTitle && ( @@ -516,16 +512,28 @@ export function SessionView({ - + } onSubmit={handleSubmit} onBashCommand={onBashCommand} onCancel={onCancelPrompt} - modeOption={modeOption} - onModeChange={modeOption ? handleModeChange : undefined} - isActiveSession={isActiveSession} /> diff --git a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx b/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx index 738197f9f..25b68370e 100644 --- a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx @@ -6,12 +6,21 @@ import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { ArrowsClockwise, CaretDown, - Check, Cpu, Robot, Spinner, } from "@phosphor-icons/react"; -import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + MenuLabel, +} from "@posthog/quill"; import { Fragment, useMemo } from "react"; import { flattenSelectOptions } from "../stores/sessionStore"; @@ -64,103 +73,78 @@ export function UnifiedModelSelector({ const otherAdapter = getOtherAdapter(adapter); - const handleModelSelect = (value: string) => { - onModelChange?.(value); - }; - - const triggerStyle = { - fontSize: "var(--font-size-1)", - color: "var(--gray-11)", - padding: "4px 8px", - height: "auto", - minHeight: "unset", - gap: "6px", - userSelect: "none" as const, - }; - if (isConnecting) { return ( - ); } - const renderModelItems = (models: { value: string; name: string }[]) => - models.map((model) => ( - handleModelSelect(model.value)} - > - - - {model.name} - - - )); - return ( - - - - - - - - - {ADAPTER_ICONS[adapter]} - {ADAPTER_LABELS[adapter]} - - - {groupedOptions.length > 0 - ? groupedOptions.map((group, index) => ( - - {index > 0 && } - {group.name} - {renderModelItems(group.options)} - - )) - : renderModelItems(options)} + + {ADAPTER_ICONS[adapter]} + + {currentLabel ?? "Model"} + + + } + /> + + {ADAPTER_LABELS[adapter]} + onModelChange?.(value)} + > + {groupedOptions.length > 0 + ? groupedOptions.map((group, index) => ( + + {index > 0 && } + {group.name} + {group.options.map((model) => ( + + {model.name} + + ))} + + )) + : options.map((model) => ( + + {model.name} + + ))} + - + - onAdapterChange(otherAdapter)} - color="gray" - > - - - Switch to {ADAPTER_LABELS[otherAdapter]} - - - - + onAdapterChange(otherAdapter)}> + + Switch to {ADAPTER_LABELS[otherAdapter]} + + + ); } diff --git a/apps/code/src/renderer/features/settings/components/sections/ClaudeCodeSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/ClaudeCodeSettings.tsx index 7162f105c..68753d2d9 100644 --- a/apps/code/src/renderer/features/settings/components/sections/ClaudeCodeSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/ClaudeCodeSettings.tsx @@ -196,9 +196,9 @@ export function ClaudeCodeSettings() { - Bypass is enabled. All actions (shell commands, file edits, web - requests) run without approval. Use shift+tab to cycle to this mode - per session. + Auto-accept is enabled. All actions (shell commands, file edits, web + requests) run without approval. Pick this mode from the mode menu in + the prompt input per session. )} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 819425ddd..b8aaf5974 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -270,7 +270,7 @@ function SidebarMenuComponent() { }, [setEditingTaskId]); return ( - + diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx index 8d9572577..27c16b43c 100644 --- a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx @@ -446,7 +446,7 @@ function CloudChangesPanel({ taskId, task }: ChangesPanelProps) { } return ( - + {isRunActive && ( @@ -564,7 +564,7 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) { : [{ files: changedFiles }]; return ( - + {fileGroups.map(({ files, header }) => ( diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index f61ac46fc..d6c07c749 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -1,3 +1,4 @@ +import { TourHighlight } from "@components/TourHighlight"; import { EnvironmentSelector } from "@features/environments/components/EnvironmentSelector"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; @@ -10,31 +11,32 @@ import { createBranch, getBranchNameInputState, } from "@features/git-interaction/utils/branchCreation"; -import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; -import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; +import { PromptInput } from "@features/message-editor/components/PromptInput"; +import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; +import type { EditorHandle } from "@features/message-editor/types"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; -import { - cycleModeOption, - getCurrentModeFromConfigOptions, -} from "@features/sessions/stores/sessionStore"; +import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; +import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; +import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; +import { useConnectivity } from "@hooks/useConnectivity"; import { useGithubBranches, useRepositoryIntegration, } from "@hooks/useIntegrations"; +import { ButtonGroup } from "@posthog/quill"; import { Flex, Text } from "@radix-ui/themes"; import { useAuthStore } from "@renderer/features/auth/stores/authStore"; -import { useTRPC } from "@renderer/trpc/client"; +import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { useNavigationStore } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { getFilePath } from "@utils/getFilePath"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; -import { TaskInputEditor } from "./TaskInputEditor"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; const DOT_FILL = "var(--gray-6)"; @@ -62,13 +64,14 @@ export function TaskInput({ setLastUsedAdapter, lastUsedCloudRepository, setLastUsedCloudRepository, + allowBypassPermissions, setLastUsedEnvironment, getLastUsedEnvironment, defaultInitialTaskMode, lastUsedInitialTaskMode, } = useSettingsStore(); - const editorRef = useRef(null); + const editorRef = useRef(null); const containerRef = useRef(null); const dragCounterRef = useRef(0); @@ -283,28 +286,65 @@ export function TaskInput({ : undefined, }); - const handleCycleMode = useCallback(() => { - const nextValue = cycleModeOption(modeOption); - if (nextValue && modeOption) { - setConfigOption(modeOption.id, nextValue); - } - }, [modeOption, setConfigOption]); - - // Global shift+tab to cycle mode regardless of focus - useHotkeys( - "shift+tab", - (e) => { - e.preventDefault(); - handleCycleMode(); + const handleModeChange = useCallback( + (value: string) => { + if (modeOption) { + setConfigOption(modeOption.id, value); + } + }, + [modeOption, setConfigOption], + ); + + const handleModelChange = useCallback( + (value: string) => { + if (modelOption) { + setConfigOption(modelOption.id, value); + } }, - { - enableOnFormTags: true, - enableOnContentEditable: true, - enabled: !!modeOption, + [modelOption, setConfigOption], + ); + + const handleThoughtChange = useCallback( + (value: string) => { + if (thoughtOption) { + setConfigOption(thoughtOption.id, value); + } }, - [handleCycleMode, modeOption], + [thoughtOption, setConfigOption], ); + const { isOnline } = useConnectivity(); + const promptSessionId = sessionId; + + // Populate command list for @ file mentions + / skills on mount + useEffect(() => { + let cancelled = false; + trpcClient.skills.list.query().then((skills) => { + if (cancelled) return; + useDraftStore.getState().actions.setCommands( + promptSessionId, + skills.map((s) => ({ name: s.name, description: s.description })), + ); + }); + return () => { + cancelled = true; + useDraftStore.getState().actions.clearCommands(promptSessionId); + }; + }, [promptSessionId]); + + const hasHistory = useTaskInputHistoryStore((s) => s.prompts.length > 0); + const getPromptHistory = useCallback( + () => useTaskInputHistoryStore.getState().prompts, + [], + ); + const hints = [ + "@ to add files", + "/ for skills", + hasHistory ? "\u2191\u2193 for history" : "", + ] + .filter(Boolean) + .join(", "); + useAutoFocusOnTyping(editorRef, isCreatingTask); const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -411,7 +451,7 @@ export function TaskInput({ - {workspaceMode === "cloud" ? ( - - ) : ( - - )} - {workspaceMode === "worktree" && ( )} + + {workspaceMode === "cloud" ? ( + + ) : ( + + )} + + {cloudRegion === "dev" && ( - + + } - directoryTooltip={ - workspaceMode === "cloud" - ? "Select a repository first" - : "Select a folder first" + reasoningSelector={ + !isPreviewLoading && ( + + ) } + getPromptHistory={getPromptHistory} onEmptyChange={setEditorIsEmpty} - adapter={adapter} - modelOption={modelOption} - thoughtOption={thoughtOption} - onConfigOptionChange={setConfigOption} - onAdapterChange={setAdapter} - isLoading={isPreviewLoading} - /> - - { + if (canSubmit) handleSubmit(); + }} /> diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx deleted file mode 100644 index 87bbac10e..000000000 --- a/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import "@features/message-editor/components/message-editor.css"; -import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { TourHighlight } from "@components/TourHighlight"; -import { AttachmentsBar } from "@features/message-editor/components/AttachmentsBar"; -import { EditorToolbar } from "@features/message-editor/components/EditorToolbar"; -import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; -import { useTiptapEditor } from "@features/message-editor/tiptap/useTiptapEditor"; -import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; -import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; -import type { AgentAdapter } from "@features/settings/stores/settingsStore"; -import { useConnectivity } from "@hooks/useConnectivity"; -import { ArrowUp } from "@phosphor-icons/react"; -import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { EditorContent } from "@tiptap/react"; -import { forwardRef, useCallback, useEffect, useImperativeHandle } from "react"; -import "./TaskInput.css"; - -interface TaskInputEditorProps { - sessionId: string; - repoPath: string; - isCreatingTask: boolean; - canSubmit: boolean; - onSubmit: () => void; - hasDirectory: boolean; - directoryTooltip?: string; - onEmptyChange?: (isEmpty: boolean) => void; - adapter?: "claude" | "codex"; - modelOption?: SessionConfigOption; - thoughtOption?: SessionConfigOption; - onConfigOptionChange?: (configId: string, value: string) => void; - onAdapterChange?: (adapter: AgentAdapter) => void; - isLoading?: boolean; - autoFocus?: boolean; - tourHighlight?: "model-selector" | "submit-button" | null; -} - -export const TaskInputEditor = forwardRef< - MessageEditorHandle, - TaskInputEditorProps ->( - ( - { - sessionId, - repoPath, - isCreatingTask, - canSubmit, - onSubmit, - hasDirectory, - directoryTooltip = "Select a folder first", - onEmptyChange, - adapter, - modelOption, - thoughtOption, - onConfigOptionChange, - onAdapterChange, - isLoading, - autoFocus = true, - tourHighlight, - }, - ref, - ) => { - const { isOnline } = useConnectivity(); - const isSubmitDisabled = isCreatingTask || !isOnline; - - const hasHistory = useTaskInputHistoryStore((s) => s.prompts.length > 0); - - const getPromptHistory = useCallback( - () => useTaskInputHistoryStore.getState().prompts, - [], - ); - - const hints = [ - "@ to add files", - "/ for skills", - hasHistory ? "\u2191\u2193 for history" : "", - ] - .filter(Boolean) - .join(", "); - - const { - editor, - isEmpty, - focus, - blur, - clear, - getText, - getContent, - setContent, - insertChip, - attachments, - addAttachment, - removeAttachment, - } = useTiptapEditor({ - sessionId, - placeholder: `What do you want to ship? ${hints}`, - disabled: isCreatingTask, - submitDisabled: !isOnline, - isLoading: isCreatingTask, - autoFocus, - context: { repoPath }, - capabilities: { commands: true, bashMode: false }, - clearOnSubmit: false, - getPromptHistory, - onSubmit: (text) => { - if (text && canSubmit) { - onSubmit(); - } - }, - onEmptyChange, - }); - - useEffect(() => { - let cancelled = false; - trpcClient.skills.list.query().then((skills) => { - if (cancelled) return; - useDraftStore.getState().actions.setCommands( - sessionId, - skills.map((s) => ({ name: s.name, description: s.description })), - ); - }); - return () => { - cancelled = true; - useDraftStore.getState().actions.clearCommands(sessionId); - }; - }, [sessionId]); - - useImperativeHandle( - ref, - () => ({ - focus, - blur, - clear, - isEmpty: () => isEmpty, - getContent, - getText, - setContent, - insertChip, - addAttachment, - removeAttachment, - }), - [ - focus, - blur, - clear, - isEmpty, - getContent, - getText, - setContent, - insertChip, - addAttachment, - removeAttachment, - ], - ); - - const getSubmitTooltip = () => { - if (isCreatingTask) return "Creating task..."; - if (!isOnline) return "You're offline — send when reconnected"; - if (isEmpty) return "Enter a task description"; - if (!hasDirectory) return directoryTooltip; - if (!canSubmit) return "Missing required fields"; - return "Create task"; - }; - - const handleModelChange = (value: string) => { - if (modelOption) { - onConfigOptionChange?.(modelOption.id, value); - } - }; - - const handleThoughtChange = (value: string) => { - if (thoughtOption) { - onConfigOptionChange?.(thoughtOption.id, value); - } - }; - - return ( - - { - const target = e.target as HTMLElement; - if (!target.closest(".ProseMirror")) { - focus(); - } - }} - > - - - - - > - - - - - - - - - - - - {})} - disabled={isCreatingTask} - isConnecting={isLoading} - onModelChange={handleModelChange} - /> - - {!isLoading && ( - - )} - - - - - { - e.stopPropagation(); - onSubmit(); - }} - disabled={!canSubmit || isSubmitDisabled} - loading={isCreatingTask} - style={{ - backgroundColor: - !canSubmit || isSubmitDisabled - ? "var(--accent-a4)" - : undefined, - color: - !canSubmit || isSubmitDisabled - ? "var(--accent-8)" - : undefined, - }} - > - - - - - - - ); - }, -); - -TaskInputEditor.displayName = "TaskInputEditor"; diff --git a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx b/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx index cd52a7232..6bdabd8c4 100644 --- a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx +++ b/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx @@ -2,10 +2,28 @@ import { useSandboxEnvironments } from "@features/settings/hooks/useSandboxEnvir import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; -import { ArrowsSplit, Cloud, Laptop, Plus } from "@phosphor-icons/react"; -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; -import type { Responsive } from "@radix-ui/themes/dist/esm/props/prop-def.js"; +import { + ArrowsSplit, + CaretDown, + Cloud, + Laptop, + Plus, +} from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + ItemContent, + ItemDescription, + ItemMedia, + ItemMenuItem, + ItemTitle, + MenuLabel, +} from "@posthog/quill"; import { useCallback, useMemo, useState } from "react"; export type { WorkspaceMode }; @@ -13,13 +31,10 @@ export type { WorkspaceMode }; interface WorkspaceModeSelectProps { value: WorkspaceMode; onChange: (mode: WorkspaceMode) => void; - size?: Responsive<"1" | "2">; + size?: "1" | "2"; disabled?: boolean; - /** Override the available modes instead of deriving from feature flags */ overrideModes?: WorkspaceMode[]; - /** Currently selected cloud environment ID (only relevant when mode is "cloud") */ selectedCloudEnvironmentId?: string | null; - /** Called when a specific cloud environment is selected */ onCloudEnvironmentChange?: (envId: string | null) => void; } @@ -33,28 +48,21 @@ const LOCAL_MODES: { mode: "worktree", label: "Worktree", description: "Create a copy of your local project to work in parallel", - icon: ( - - ), + icon: , }, { mode: "local", label: "Local", description: "Edits your repo directly on current branch", - icon: , + icon: , }, ]; -const CLOUD_ICON = ; +const CLOUD_ICON = ; export function WorkspaceModeSelect({ value, onChange, - size = "1", disabled, overrideModes, selectedCloudEnvironmentId, @@ -104,141 +112,123 @@ export function WorkspaceModeSelect({ }, [value]); return ( - - - - - - - {localModes.map((item) => ( - { - onChange(item.mode); - onCloudEnvironmentChange?.(null); - }} - style={{ padding: "6px 8px", height: "auto" }} + + -
- - {item.icon} - -
- {item.label} - - {item.description} - -
-
-
- ))} + {triggerIcon} + {triggerLabel} + + + } + /> + + + {localModes.map((item) => ( + { + onChange(item.mode); + onCloudEnvironmentChange?.(null); + }} + render={ + + + {item.icon} + + + {item.label} + + {item.description} + + + + } + /> + ))} + {showCloud && ( <> - -
- - Cloud environments - + +
+ Cloud environments
- { - onChange("cloud"); - onCloudEnvironmentChange?.(null); - }} - style={{ padding: "6px 8px", height: "auto" }} - > -
- - {CLOUD_ICON} - -
- Default - - Full network access - -
-
-
- - {environments.map((env) => ( - { + + { onChange("cloud"); - onCloudEnvironmentChange?.(env.id); + onCloudEnvironmentChange?.(null); }} - style={{ padding: "6px 8px", height: "auto" }} - > -
+ + {CLOUD_ICON} + + + Default + + Full network access + + + + } + /> + + {environments.map((env) => ( + { + onChange("cloud"); + onCloudEnvironmentChange?.(env.id); }} - > - - {CLOUD_ICON} - -
- {env.name} - - {env.network_access_level === "full" - ? "Full network access" - : env.network_access_level === "trusted" - ? "Trusted sources only" - : `${env.allowed_domains.length} allowed domain${env.allowed_domains.length !== 1 ? "s" : ""}`} - -
-
-
- ))} + render={ + + + {CLOUD_ICON} + + + {env.name} + + {env.network_access_level === "full" + ? "Full network access" + : env.network_access_level === "trusted" + ? "Trusted sources only" + : `${env.allowed_domains.length} allowed domain${env.allowed_domains.length !== 1 ? "s" : ""}`} + + + + } + /> + ))} + )} - - + + ); } diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index f508afef6..4df29ab71 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -1,7 +1,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { buildCloudTaskDescription } from "@features/editor/utils/cloud-prompt"; -import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; +import type { EditorHandle } from "@features/message-editor/types"; import { contentToXml, extractFilePaths, @@ -22,7 +22,7 @@ import type { TaskCreationInput, TaskService } from "../service/service"; const log = logger.scope("task-creation"); interface UseTaskCreationOptions { - editorRef: React.RefObject; + editorRef: React.RefObject; selectedDirectory: string; selectedRepository?: string | null; githubIntegrationId?: number; diff --git a/apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts b/apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts index cba1917b9..fb629ec1f 100644 --- a/apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts +++ b/apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts @@ -1,8 +1,8 @@ -import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; +import type { EditorHandle } from "@features/message-editor/types"; import { type RefObject, useEffect } from "react"; export function useAutoFocusOnTyping( - editorRef: RefObject, + editorRef: RefObject, disabled = false, ) { useEffect(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30822a640..7e9af6d86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,8 +170,8 @@ importers: specifier: ^0.0.48 version: 0.0.48(prop-types@15.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@posthog/quill': - specifier: 0.1.0-alpha.6 - version: 0.1.0-alpha.6(@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) + specifier: 0.1.0-alpha.7 + version: 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/shared': specifier: workspace:* version: link:../../packages/shared @@ -3596,8 +3596,8 @@ packages: '@posthog/plugin-utils@1.0.1': resolution: {integrity: sha512-+x3LFZlUVPNUPQBx9kvRV/hXd1j3JcYf9iYClJCORjuuA0RV5FICUZMMFbwsSOPuKoW9rQAaW3zFoQMYEfM+YA==} - '@posthog/quill@0.1.0-alpha.6': - resolution: {integrity: sha512-93GbwWEwTbJBC8If860r7QYFIVV2AF5786hAm38gJzKhM5Xb1WPF5JyW3MiZWqGCWW+KfS8Lj91mL43+nmiZcg==} + '@posthog/quill@0.1.0-alpha.7': + resolution: {integrity: sha512-meJd4gEUchZHx71GJ3NdZ7j/NxIAai5Srda6IQP3pJEMpY3VddwHn+r/xUG42eHt88vBbZsnlCwvyLYXhla5eQ==} engines: {node: '>=20'} peerDependencies: react: ^19.0.0 @@ -15153,7 +15153,7 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@posthog/quill@0.1.0-alpha.6(@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.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)': 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 @@ -21693,7 +21693,7 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 path-scurry@2.0.1: dependencies: