diff --git a/core/index.d.ts b/core/index.d.ts index 5de05fbf81..c7ac930cad 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -439,7 +439,7 @@ export interface PromptLog { completion: string; } -export type MessageModes = "chat" | "edit"; +export type MessageModes = "chat" | "edit" | "agent"; export type ToolStatus = | "generating" diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt index 3c6b27f056..a4e060265a 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt @@ -126,12 +126,3 @@ class OpenConfigAction : AnAction() { continuePluginService.sendToWebview("navigateTo", params) } } - -class OpenMorePageAction : AnAction() { - override fun actionPerformed(e: AnActionEvent) { - val continuePluginService = getContinuePluginService(e.project) ?: return - continuePluginService.continuePluginWindow?.content?.components?.get(0)?.requestFocus() - val params = mapOf("path" to "/more", "toggle" to true) - continuePluginService.sendToWebview("navigateTo", params) - } -} \ No newline at end of file diff --git a/extensions/intellij/src/main/resources/META-INF/plugin.xml b/extensions/intellij/src/main/resources/META-INF/plugin.xml index abc50a0920..5911365514 100644 --- a/extensions/intellij/src/main/resources/META-INF/plugin.xml +++ b/extensions/intellij/src/main/resources/META-INF/plugin.xml @@ -140,19 +140,10 @@ - - - - - { - vscode.commands.executeCommand("continue.navigateTo", "/more", true); - }, "continue.navigateTo": (path: string, toggle: boolean) => { sidebar.webviewProtocol?.request("navigateTo", { path, toggle }); focusGUI(); diff --git a/gui/editorInset/index.html b/gui/editorInset/index.html deleted file mode 100644 index 1e6607ff2f..0000000000 --- a/gui/editorInset/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Continue - - -
- - - diff --git a/gui/editorInset/vite.config.ts b/gui/editorInset/vite.config.ts deleted file mode 100644 index fdc63bbdb8..0000000000 --- a/gui/editorInset/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import react from "@vitejs/plugin-react-swc"; -import tailwindcss from "tailwindcss"; -import { defineConfig } from "vite"; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], - build: { - // Change the output .js filename to not include a hash - rollupOptions: { - // external: ["vscode-webview"], - output: { - entryFileNames: `assets/[name].js`, - chunkFileNames: `assets/[name].js`, - assetFileNames: `assets/[name].[ext]`, - }, - }, - }, -}); diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 73d6672927..deb1e376f2 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,19 +1,16 @@ -import { useDispatch } from "react-redux"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; import Layout from "./components/Layout"; +import { SubmenuContextProvidersProvider } from "./context/SubmenuContextProviders"; import { VscThemeProvider } from "./context/VscTheme"; import useSetup from "./hooks/useSetup"; import { AddNewModel, ConfigureProvider } from "./pages/AddNewModel"; +import ConfigPage from "./pages/config"; import ConfigErrorPage from "./pages/config-error"; import ErrorPage from "./pages/error"; import Chat from "./pages/gui"; import History from "./pages/history"; -import MigrationPage from "./pages/migration"; -import MorePage from "./pages/More"; import Stats from "./pages/stats"; import { ROUTES } from "./util/navigation"; -import { SubmenuContextProvidersProvider } from "./context/SubmenuContextProviders"; -import ConfigPage from "./pages/config"; const router = createMemoryRouter([ { @@ -45,10 +42,6 @@ const router = createMemoryRouter([ path: "/addModel/provider/:providerName", element: , }, - { - path: "/more", - element: , - }, { path: ROUTES.CONFIG_ERROR, element: , @@ -57,10 +50,6 @@ const router = createMemoryRouter([ path: ROUTES.CONFIG, element: , }, - { - path: "/migration", - element: , - }, ], }, ]); diff --git a/gui/src/components/ConversationStarters/ConversationStarterCard.tsx b/gui/src/components/ConversationStarters/ConversationStarterCard.tsx index 1542adadf5..f0428a7232 100644 --- a/gui/src/components/ConversationStarters/ConversationStarterCard.tsx +++ b/gui/src/components/ConversationStarters/ConversationStarterCard.tsx @@ -1,62 +1,37 @@ -import { BookmarkIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline"; -import { BookmarkIcon as BookmarkIconSolid } from "@heroicons/react/24/solid"; +import { ChatBubbleLeftIcon } from "@heroicons/react/24/outline"; import { SlashCommandDescription } from "core"; import { useState } from "react"; -import { defaultBorderRadius, GhostButton, vscInputBackground } from ".."; +import { defaultBorderRadius, vscInputBackground } from ".."; interface ConversationStarterCardProps { command: SlashCommandDescription; onClick: (command: SlashCommandDescription) => void; - onBookmark: (command: SlashCommandDescription) => void; - isBookmarked: boolean; } export function ConversationStarterCard({ command, onClick, - onBookmark, - isBookmarked = false, }: ConversationStarterCardProps) { const [isHovered, setIsHovered] = useState(false); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onClick={() => onClick(command)} > -
-
+
+
-
onClick(command)} - className="flex flex-1 flex-col hover:cursor-pointer" - > -
{command.name}
-
{command.description}
-
-
- { - e.stopPropagation(); - onBookmark(command); - }} - aria-label={ - isBookmarked ? "Remove bookmarked prompt" : "Bookmark prompt" - } - className={isHovered || isBookmarked ? "opacity-100" : "opacity-0"} - > - {isBookmarked ? ( - - ) : ( - - )} - +
+
{command.name}
+
{command.description}
diff --git a/gui/src/components/ConversationStarters/ConversationStarterCards.tsx b/gui/src/components/ConversationStarters/ConversationStarterCards.tsx index 921d47d6e7..2a29d07498 100644 --- a/gui/src/components/ConversationStarters/ConversationStarterCards.tsx +++ b/gui/src/components/ConversationStarters/ConversationStarterCards.tsx @@ -9,8 +9,7 @@ const NUM_CARDS_TO_RENDER = 3; export function ConversationStarterCards() { const dispatch = useAppDispatch(); - const { cmdsSortedByBookmark, bookmarkStatuses, toggleBookmark } = - useBookmarkedSlashCommands(); + const { cmdsSortedByBookmark } = useBookmarkedSlashCommands(); function onClick(command: SlashCommandDescription) { if (command.prompt) { @@ -31,8 +30,6 @@ export function ConversationStarterCards() { key={command.name + i} command={command} onClick={onClick} - onBookmark={() => toggleBookmark(command)} - isBookmarked={bookmarkStatuses[command.name]} /> ))}
diff --git a/gui/src/components/Layout.tsx b/gui/src/components/Layout.tsx index b098bda707..20ecc77610 100644 --- a/gui/src/components/Layout.tsx +++ b/gui/src/components/Layout.tsx @@ -17,7 +17,7 @@ import { import { setShowDialog } from "../redux/slices/uiSlice"; import { exitEditMode } from "../redux/thunks"; import { loadLastSession, saveCurrentSession } from "../redux/thunks/session"; -import { getFontSize, isMetaEquivalentKeyPressed } from "../util"; +import { fontSize, isMetaEquivalentKeyPressed } from "../util"; import { incrementFreeTrialCount } from "../util/freeTrial"; import { ROUTES } from "../util/navigation"; import TextDialog from "./dialogs"; @@ -305,10 +305,7 @@ const Layout = () => {
-
+
); diff --git a/gui/src/components/PageHeader.tsx b/gui/src/components/PageHeader.tsx index d37d82e6f9..c6f55e67e2 100644 --- a/gui/src/components/PageHeader.tsx +++ b/gui/src/components/PageHeader.tsx @@ -1,4 +1,5 @@ import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { fontSize } from "../util"; export interface PageHeaderProps { onTitleClick?: () => void; @@ -15,7 +16,7 @@ export default function PageHeader({ }: PageHeaderProps) { return (
- + {title}
diff --git a/gui/src/components/StepContainer/StepContainer.tsx b/gui/src/components/StepContainer/StepContainer.tsx index 102d0151dd..01560e595d 100644 --- a/gui/src/components/StepContainer/StepContainer.tsx +++ b/gui/src/components/StepContainer/StepContainer.tsx @@ -20,8 +20,10 @@ interface StepContainerProps { } const ContentDiv = styled.div<{ fontSize?: number }>` - padding-top: 4px; - padding-bottom: 4px; + padding: 4px; + padding-left: 6px; + padding-right: 6px; + background-color: ${vscBackground}; font-size: ${getFontSize()}px; overflow: hidden; diff --git a/gui/src/components/dialogs/AddDocsDialog.tsx b/gui/src/components/dialogs/AddDocsDialog.tsx index d421d94c48..f8006cee70 100644 --- a/gui/src/components/dialogs/AddDocsDialog.tsx +++ b/gui/src/components/dialogs/AddDocsDialog.tsx @@ -9,7 +9,7 @@ import { useAppSelector } from "../../redux/hooks"; import { updateIndexingStatus } from "../../redux/slices/indexingSlice"; import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice"; import { ToolTip } from "../gui/Tooltip"; -import DocsIndexingPeeks from "../indexing/DocsIndexingPeeks"; +import DocsIndexingPeeks from "../mainInput/Lump/sections/docs/DocsIndexingPeeks"; function AddDocsDialog() { const posthog = usePostHog(); diff --git a/gui/src/components/gui/Tooltip.tsx b/gui/src/components/gui/Tooltip.tsx index b1477ba73a..c39bff23f5 100644 --- a/gui/src/components/gui/Tooltip.tsx +++ b/gui/src/components/gui/Tooltip.tsx @@ -1,10 +1,10 @@ import ReactDOM from "react-dom"; import { Tooltip } from "react-tooltip"; import { vscBackground, vscForeground, vscInputBorder } from ".."; -import { getFontSize } from "../../util"; +import { fontSize } from "../../util"; const TooltipStyles = { - fontSize: `${getFontSize() - 2}px`, + fontSize: fontSize(-2), backgroundColor: vscBackground, outline: `0.5px solid ${vscInputBorder}`, color: vscForeground, @@ -25,7 +25,7 @@ export function ToolTip(props: any) { return ( tooltipPortalDiv && ReactDOM.createPortal( - , + , tooltipPortalDiv, ) ); diff --git a/gui/src/components/index.ts b/gui/src/components/index.ts index eb0632646e..e4aad87c26 100644 --- a/gui/src/components/index.ts +++ b/gui/src/components/index.ts @@ -59,6 +59,7 @@ export const vscListActiveForeground = `var(${VSC_LIST_ACTIVE_FOREGROUND_VAR}, $ export const vscInputBorder = `var(${VSC_INPUT_BORDER_VAR}, ${lightGray})`; export const vscInputBorderFocus = `var(${VSC_FOCUS_BORDER_VAR}, ${lightGray})`; export const vscBadgeBackground = `var(${VSC_BADGE_BACKGROUND_VAR}, #1bbe84)`; +export const vscBadgeForeground = `var(${VSC_BADGE_FOREGROUND_VAR}, #ffffff)`; export const vscCommandCenterActiveBorder = `var(${VSC_COMMAND_CENTER_ACTIVE_BORDER_VAR}, #1bbe84)`; export const vscCommandCenterInactiveBorder = `var(${VSC_COMMAND_CENTER_INACTIVE_BORDER_VAR}, #1bbe84)`; export const vscFindMatchSelected = `var(${VSC_FIND_MATCH_SELECTED_VAR}, rgba(255, 223, 0))`; diff --git a/gui/src/components/indexing/ChatIndexingPeeks.tsx b/gui/src/components/indexing/ChatIndexingPeeks.tsx index aa5edfe327..a7eaed7fb2 100644 --- a/gui/src/components/indexing/ChatIndexingPeeks.tsx +++ b/gui/src/components/indexing/ChatIndexingPeeks.tsx @@ -1,8 +1,8 @@ -import { useDispatch } from "react-redux"; +import { ArrowPathIcon, EyeSlashIcon } from "@heroicons/react/24/outline"; import { IndexingStatus } from "core"; import { useMemo } from "react"; +import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; -import { ArrowPathIcon, EyeSlashIcon } from "@heroicons/react/24/outline"; import { useAppSelector } from "../../redux/hooks"; import { setIndexingChatPeekHidden } from "../../redux/slices/indexingSlice"; @@ -32,12 +32,7 @@ function ChatIndexingPeek({ state }: ChatIndexingPeekProps) { if (hiddenPeeks[state.type]) return null; return ( -
{ - navigate("/more"); - }} - > +

diff --git a/gui/src/components/indexing/DocsIndexingStatus.tsx b/gui/src/components/indexing/DocsIndexingStatus.tsx deleted file mode 100644 index 224d96265b..0000000000 --- a/gui/src/components/indexing/DocsIndexingStatus.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { IndexingStatus, SiteIndexingConfig } from "core"; -import { useContext, useMemo, useState } from "react"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { - ArrowPathIcon, - ArrowTopRightOnSquareIcon, - CheckCircleIcon, - ExclamationTriangleIcon, - EyeIcon, - PauseCircleIcon, - TrashIcon, -} from "@heroicons/react/24/outline"; -import { useAppDispatch, useAppSelector } from "../../redux/hooks"; -import { updateIndexingStatus } from "../../redux/slices/indexingSlice"; -import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice"; -import ConfirmationDialog from "../dialogs/ConfirmationDialog"; -import DocsDetailsDialog from "./DocsDetailsDialog"; - -interface IndexingStatusViewerProps { - docConfig: SiteIndexingConfig; -} - -const STATUS_TO_ICON: Record = { - indexing: ArrowPathIcon, - paused: PauseCircleIcon, - complete: CheckCircleIcon, - aborted: null, - pending: null, - failed: ExclamationTriangleIcon, // Since we show an error message below -}; - -function DocsIndexingStatus({ docConfig }: IndexingStatusViewerProps) { - const config = useAppSelector((store) => store.config.config); - const ideMessenger = useContext(IdeMessengerContext); - const dispatch = useAppDispatch(); - - const status = useAppSelector( - (store) => store.indexing.indexing.statuses[docConfig.startUrl], - ); - - const reIndex = () => - ideMessenger.post("indexing/reindex", { - type: "docs", - id: docConfig.startUrl, - }); - - const abort = () => { - ideMessenger.post("indexing/abort", { - type: "docs", - id: docConfig.startUrl, - }); - // Optimistic abort status - if (status) { - dispatch( - updateIndexingStatus({ ...status, status: "aborted", progress: 0 }), - ); - } - }; - - const [hasDeleted, setHasDeleted] = useState(false); // simple alternative to optimistic redux update - const onDelete = () => { - // optimistic update - dispatch( - setDialogMessage( - { - ideMessenger.post("context/removeDocs", { - startUrl: docConfig.startUrl, - }); - setHasDeleted(true); - }} - />, - ), - ); - dispatch(setShowDialog(true)); - }; - - const progressPercentage = useMemo(() => { - if (!status) { - return 0; - } - return Math.min(100, Math.max(0, status.progress * 100)).toFixed(0); - }, [status?.progress]); - - const Icon = STATUS_TO_ICON[status?.status]; - const showProgressPercentage = progressPercentage !== "100"; - - if (hasDeleted) return null; - - return ( -

-
-
{ - if (status?.url) { - ideMessenger.post("openUrl", status.url); - } - }} - > - {docConfig.faviconUrl ? ( - doc icon - ) : null} -

- {docConfig.title ?? docConfig.startUrl} -

- -
- {status?.status === "pending" ? ( -
Pending...
- ) : ( -
- {showProgressPercentage && ( - {progressPercentage}% - )} - {status?.status !== "indexing" ? ( - - ) : null} - {Icon ? ( - - ) : null} -
- )} -
- -
-
-
- -
- {}, - pending: () => {}, - }[status?.status] - } - > - {config.disableIndexing - ? "Indexing disabled" - : { - complete: "Click to re-index", - indexing: "Cancel indexing", - failed: "Click to retry", - aborted: "Click to index", - paused: "", - pending: "", - }[status?.status]} - -
- {status?.description === "Github rate limit exceeded" ? ( - - ideMessenger.post( - "openUrl", - "https://docs.continue.dev/customize/deep-dives/docs#github", - ) - } - > - {status.description} - - ) : ( - - {status?.description} - - )} - {status?.status === "complete" ? ( - { - dispatch(setShowDialog(true)); - dispatch( - setDialogMessage( - , - ), - ); - }} - > - Add Docs - - ) : null} -
-
-
- ); -} - -export default DocsIndexingStatus; diff --git a/gui/src/components/indexing/DocsIndexingStatuses.tsx b/gui/src/components/indexing/DocsIndexingStatuses.tsx deleted file mode 100644 index f1e261dfb3..0000000000 --- a/gui/src/components/indexing/DocsIndexingStatuses.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useDispatch } from "react-redux"; -import { SecondaryButton } from ".."; -import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice"; -import AddDocsDialog from "../dialogs/AddDocsDialog"; -import DocsIndexingStatus from "./DocsIndexingStatus"; -import { useAppSelector } from "../../redux/hooks"; -import { useContext, useMemo } from "react"; -import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { IndexingStatus } from "core"; - -function DocsIndexingStatuses() { - const dispatch = useDispatch(); - const config = useAppSelector((store) => store.config.config); - const ideMessenger = useContext(IdeMessengerContext); - const indexingStatuses = useAppSelector( - (store) => store.indexing.indexing.statuses, - ); - - const hasDocsProvider = useMemo(() => { - return !!config.contextProviders?.some( - (provider) => provider.title === "docs", - ); - }, [config]); - - // TODO - this might significantly impact performance during indexing - const sortedConfigDocs = useMemo(() => { - const sorter = (status: IndexingStatus["status"]) => { - // TODO - further sorting? - if (status === "indexing" || status === "paused") return 0; - if (status === "failed") return 1; - if (status === "aborted" || status === "pending") return 2; - return 3; - }; - - const docs = [...(config.docs ?? [])]; - docs.sort((a, b) => - sorter(indexingStatuses[b.startUrl]?.status ?? "pending") > - sorter(indexingStatuses[a.startUrl]?.status ?? "pending") - ? -1 - : 1, - ); - return docs; - }, [config, indexingStatuses]); - - return ( -
-
-

@docs indexes

- {sortedConfigDocs.length ? ( - { - dispatch(setShowDialog(true)); - dispatch(setDialogMessage()); - }} - > - Add - - ) : null} -
- - {hasDocsProvider ? ( - sortedConfigDocs.length ? ( - "Manage your documentation sources" - ) : ( - "No docs yet" - ) - ) : ( -
-
-
- -
- - @docs is not in your config - -
- { - ideMessenger.post("config/addContextProvider", { - name: "docs", - params: {}, - }); - }} - > - Add @docs to my config - -
- )} -
-
-
- {sortedConfigDocs.length === 0 && ( - { - dispatch(setShowDialog(true)); - dispatch(setDialogMessage()); - }} - > - Add Docs - - )} -
- {sortedConfigDocs.map((doc) => { - return ; - })} -
-
- ); -} - -export default DocsIndexingStatuses; diff --git a/gui/src/components/mainInput/MentionList.tsx b/gui/src/components/mainInput/AtMentionDropdown/index.tsx similarity index 95% rename from gui/src/components/mainInput/MentionList.tsx rename to gui/src/components/mainInput/AtMentionDropdown/index.tsx index 66c2da16e6..176120153f 100644 --- a/gui/src/components/mainInput/MentionList.tsx +++ b/gui/src/components/mainInput/AtMentionDropdown/index.tsx @@ -22,15 +22,15 @@ import { vscListActiveBackground, vscListActiveForeground, vscQuickInputBackground, -} from ".."; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice"; -import FileIcon from "../FileIcon"; -import SafeImg from "../SafeImg"; -import AddDocsDialog from "../dialogs/AddDocsDialog"; -import HeaderButtonWithToolTip from "../gui/HeaderButtonWithToolTip"; -import { NAMED_ICONS } from "./icons"; -import { ComboBoxItem, ComboBoxItemType } from "./types"; +} from "../.."; +import { IdeMessengerContext } from "../../../context/IdeMessenger"; +import { setDialogMessage, setShowDialog } from "../../../redux/slices/uiSlice"; +import FileIcon from "../../FileIcon"; +import SafeImg from "../../SafeImg"; +import AddDocsDialog from "../../dialogs/AddDocsDialog"; +import HeaderButtonWithToolTip from "../../gui/HeaderButtonWithToolTip"; +import { NAMED_ICONS } from "../icons"; +import { ComboBoxItem, ComboBoxItemType } from "../types"; export function getIconFromDropdownItem( id: string | undefined, @@ -129,7 +129,7 @@ const QueryInput = styled.textarea` resize: none; `; -interface MentionListProps { +interface AtMentionDropdownProps { items: ComboBoxItem[]; command: (item: any) => void; @@ -138,7 +138,7 @@ interface MentionListProps { onClose: () => void; } -const MentionList = forwardRef((props: MentionListProps, ref) => { +const AtMentionDropdown = forwardRef((props: AtMentionDropdownProps, ref) => { const dispatch = useDispatch(); const ideMessenger = useContext(IdeMessengerContext); @@ -460,4 +460,4 @@ const MentionList = forwardRef((props: MentionListProps, ref) => { ); }); -export default MentionList; +export default AtMentionDropdown; diff --git a/gui/src/components/mainInput/ContinueButton.tsx b/gui/src/components/mainInput/ContinueButton.tsx deleted file mode 100644 index 23776bbcf8..0000000000 --- a/gui/src/components/mainInput/ContinueButton.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { PlayIcon, StopIcon } from "@heroicons/react/24/outline"; -import styled from "styled-components"; -import { Button } from ".."; -import { getFontSize } from "../../util"; - -const StyledButton = styled(Button)<{ - color?: string | null; - isDisabled: boolean; - showStop: boolean; -}>` - margin: auto; - margin-top: 8px; - margin-bottom: 16px; - display: grid; - width: 130px; - grid-template-columns: 22px 1fr; - align-items: center; - background-color: ${(props) => - `${props.color || "#be1b55"}${props.showStop ? "33" : ""}`}; - - opacity: ${(props) => (props.isDisabled ? 0.5 : 1.0)}; - - border: 1px solid - ${(props) => (props.showStop ? props.color || "#be1b55" : "transparent")}; - - cursor: ${(props) => (props.isDisabled ? "default" : "pointer")}; - - &:hover:enabled { - background-color: ${(props) => - `${props.color || "#be1b55"}${props.showStop ? "33" : ""}`}; - ${(props) => - props.isDisabled - ? "cursor: default;" - : ` - opacity: 0.7; - `} - } -`; - -function ContinueButton(props: { - onClick?: () => void; - hidden?: boolean; - disabled: boolean; - showStop: boolean; -}) { - return ( - - ); -} - -export default ContinueButton; diff --git a/gui/src/components/mainInput/ContinueInputBox.tsx b/gui/src/components/mainInput/ContinueInputBox.tsx index 69264eb35f..2b91b707b5 100644 --- a/gui/src/components/mainInput/ContinueInputBox.tsx +++ b/gui/src/components/mainInput/ContinueInputBox.tsx @@ -1,14 +1,14 @@ import { Editor, JSONContent } from "@tiptap/react"; import { ContextItemWithId, InputModifiers } from "core"; -import { useDispatch } from "react-redux"; +import { useMemo, useState } from "react"; import styled, { keyframes } from "styled-components"; import { defaultBorderRadius, vscBackground } from ".."; -import { selectSlashCommandComboBoxInputs } from "../../redux/selectors"; -import ContextItemsPeek from "./ContextItemsPeek"; -import TipTapEditor from "./TipTapEditor"; import { useAppSelector } from "../../redux/hooks"; +import { selectSlashCommandComboBoxInputs } from "../../redux/selectors"; +import ContextItemsPeek from "./belowMainInput/ContextItemsPeek"; import { ToolbarOptions } from "./InputToolbar"; -import { useMemo } from "react"; +import { Lump } from "./Lump"; +import TipTapEditor from "./tiptap/TipTapEditor"; interface ContinueInputBoxProps { isEditMode?: boolean; @@ -113,9 +113,12 @@ function ContinueInputBox(props: ContinueInputBoxProps) { } : {}; + const [lumpOpen, setLumpOpen] = useState(true); + return (
+ {props.isMainInput && } diff --git a/gui/src/components/mainInput/InputToolbar.tsx b/gui/src/components/mainInput/InputToolbar.tsx index a1f9da2820..f3a8b975f2 100644 --- a/gui/src/components/mainInput/InputToolbar.tsx +++ b/gui/src/components/mainInput/InputToolbar.tsx @@ -21,6 +21,7 @@ import { import { exitEditMode } from "../../redux/thunks"; import { loadLastSession } from "../../redux/thunks/session"; import { + fontSize, getAltKeyLabel, getFontSize, getMetaKeyLabel, @@ -28,8 +29,8 @@ import { } from "../../util"; import { ToolTip } from "../gui/Tooltip"; import ModelSelect from "../modelSelection/ModelSelect"; -import HoverItem from "./InputToolbar/HoverItem"; -import ToggleToolsButton from "./InputToolbar/ToggleToolsButton"; +import ModeSelect from "../modelSelection/ModeSelect"; +import HoverItem from "./InputToolbar/bottom/HoverItem"; const StyledDiv = styled.div<{ isHidden?: boolean }>` padding-top: 4px; @@ -82,6 +83,8 @@ interface InputToolbarProps { toolbarOptions?: ToolbarOptions; disabled?: boolean; isMainInput?: boolean; + lumpOpen: boolean; + setLumpOpen: (open: boolean) => void; } function InputToolbar(props: InputToolbarProps) { @@ -112,6 +115,7 @@ function InputToolbar(props: InputToolbarProps) { className="find-widget-skip flex" >
+
{props.toolbarOptions?.hideImageUpload || @@ -131,7 +135,7 @@ function InputToolbar(props: InputToolbarProps) { /> { fileInputRef.current?.click(); @@ -147,7 +151,7 @@ function InputToolbar(props: InputToolbarProps) { @@ -155,12 +159,15 @@ function InputToolbar(props: InputToolbarProps) { )} - -
-
+
{!props.toolbarOptions?.hideUseCodebase && !isInEditMode && ( - ); -}; - -export default ToolPermissionsDialog; diff --git a/gui/src/components/mainInput/InputToolbar/HoverItem.tsx b/gui/src/components/mainInput/InputToolbar/bottom/HoverItem.tsx similarity index 65% rename from gui/src/components/mainInput/InputToolbar/HoverItem.tsx rename to gui/src/components/mainInput/InputToolbar/bottom/HoverItem.tsx index e668a69f2a..4a8aefcdc6 100644 --- a/gui/src/components/mainInput/InputToolbar/HoverItem.tsx +++ b/gui/src/components/mainInput/InputToolbar/bottom/HoverItem.tsx @@ -1,7 +1,7 @@ import styled from "styled-components"; -const HoverItem = styled.span<{ isActive?: boolean }>` - padding: 0 4px; +const HoverItem = styled.span<{ isActive?: boolean; px?: number }>` + padding: 0 ${(props) => props.px ?? 4}px; padding-top: 2px; padding-bottom: 2px; cursor: pointer; diff --git a/gui/src/components/mainInput/InputToolbar/PopoverTransition.tsx b/gui/src/components/mainInput/InputToolbar/bottom/PopoverTransition.tsx similarity index 100% rename from gui/src/components/mainInput/InputToolbar/PopoverTransition.tsx rename to gui/src/components/mainInput/InputToolbar/bottom/PopoverTransition.tsx diff --git a/gui/src/components/mainInput/InputToolbar/ToolDropdownItem.tsx b/gui/src/components/mainInput/InputToolbar/bottom/ToolDropdownItem.tsx similarity index 88% rename from gui/src/components/mainInput/InputToolbar/ToolDropdownItem.tsx rename to gui/src/components/mainInput/InputToolbar/bottom/ToolDropdownItem.tsx index 8926221247..ca423764b8 100644 --- a/gui/src/components/mainInput/InputToolbar/ToolDropdownItem.tsx +++ b/gui/src/components/mainInput/InputToolbar/bottom/ToolDropdownItem.tsx @@ -2,9 +2,10 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { Tool } from "core"; import { useEffect } from "react"; import { useDispatch } from "react-redux"; -import { useAppSelector } from "../../../redux/hooks"; -import { addTool, toggleToolSetting } from "../../../redux/slices/uiSlice"; -import { ToolTip } from "../../gui/Tooltip"; +import { useAppSelector } from "../../../../redux/hooks"; +import { addTool, toggleToolSetting } from "../../../../redux/slices/uiSlice"; +import { fontSize } from "../../../../util"; +import { ToolTip } from "../../../gui/Tooltip"; interface ToolDropdownItemProps { tool: Tool; @@ -30,7 +31,10 @@ function ToolDropdownItem(props: ToolDropdownItemProps) { return (
{ dispatch(toggleToolSetting(props.tool.function.name)); e.stopPropagation(); diff --git a/gui/src/components/mainInput/InputToolbar/bottom/ToolPermissionsDialog.tsx b/gui/src/components/mainInput/InputToolbar/bottom/ToolPermissionsDialog.tsx new file mode 100644 index 0000000000..5d3c155c2f --- /dev/null +++ b/gui/src/components/mainInput/InputToolbar/bottom/ToolPermissionsDialog.tsx @@ -0,0 +1,79 @@ +import { Tool } from "core"; +import { useMemo } from "react"; +import { useAppDispatch, useAppSelector } from "../../../../redux/hooks"; +import { toggleToolGroupSetting } from "../../../../redux/slices/uiSlice"; +import { fontSize } from "../../../../util"; +import ToggleSwitch from "../../../gui/Switch"; +import ToolDropdownItem from "./ToolDropdownItem"; + +export const ToolPermissionsDialog = () => { + const availableTools = useAppSelector((state) => state.config.config.tools); + const toolGroupSettings = useAppSelector( + (store) => store.ui.toolGroupSettings, + ); + const dispatch = useAppDispatch(); + + const toolsByGroup = useMemo(() => { + const byGroup: Record = {}; + for (const tool of availableTools) { + if (!byGroup[tool.group]) { + byGroup[tool.group] = []; + } + byGroup[tool.group].push(tool); + } + return Object.entries(byGroup); + }, [availableTools]); + + // Detect duplicate tool names + const duplicateDetection = useMemo(() => { + const counts: Record = {}; + availableTools.forEach((tool) => { + if (counts[tool.function.name]) { + counts[tool.function.name] = counts[tool.function.name] + 1; + } else { + counts[tool.function.name] = 1; + } + }); + return Object.fromEntries( + Object.entries(counts).map(([k, v]) => [k, v > 1]), + ); + }, [availableTools]); + + return ( + <> + {toolsByGroup.map(([groupName, tools]) => ( +
+
+

+ {groupName} +

+ dispatch(toggleToolGroupSetting(groupName))} + text="" + size={10} + /> +
+
+ {tools.map((tool) => ( + + ))} + {toolGroupSettings[groupName] === "exclude" && ( +
+ )} +
+
+ ))} + + ); +}; diff --git a/gui/src/components/mainInput/InputToolbar/top/TopInputToolbar.tsx b/gui/src/components/mainInput/InputToolbar/top/TopInputToolbar.tsx new file mode 100644 index 0000000000..f82ea9860e --- /dev/null +++ b/gui/src/components/mainInput/InputToolbar/top/TopInputToolbar.tsx @@ -0,0 +1,22 @@ +import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline"; +import { ToolbarOptions } from "../../InputToolbar"; +import HoverItem from "../bottom/HoverItem"; + +interface TopInputToolbarProps { + toolbarOptions?: ToolbarOptions; + onAddContextItem?: () => void; + lumpOpen: boolean; + setLumpOpen: (open: boolean) => void; +} +export function TopInputToolbar(props: TopInputToolbarProps) { + return ( +
+ props.setLumpOpen(!props.lumpOpen)} + className="ml-auto" + > + + +
+ ); +} diff --git a/gui/src/components/mainInput/Lump/BlockSettingsTopToolbar.tsx b/gui/src/components/mainInput/Lump/BlockSettingsTopToolbar.tsx new file mode 100644 index 0000000000..f6abe9e858 --- /dev/null +++ b/gui/src/components/mainInput/Lump/BlockSettingsTopToolbar.tsx @@ -0,0 +1,135 @@ +import { + BookOpenIcon, + ChatBubbleLeftIcon, + ChevronLeftIcon, + CubeIcon, + EllipsisHorizontalIcon, + PencilIcon, + Squares2X2Icon, + WrenchScrewdriverIcon, +} from "@heroicons/react/24/outline"; +import { vscBadgeBackground, vscBadgeForeground } from "../.."; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; +import { toggleBlockSettingsToolbar } from "../../../redux/slices/uiSlice"; +import { fontSize } from "../../../util"; +import AssistantSelect from "../../modelSelection/platform/AssistantSelect"; +import HoverItem from "../InputToolbar/bottom/HoverItem"; + +interface BlockSettingsToolbarIcon { + tooltip: string; + icon: React.ComponentType; + itemCount?: number; + onClick: () => void; + isSelected?: boolean; +} + +interface Section { + id: string; + tooltip: string; + icon: React.ComponentType; +} + +const sections: Section[] = [ + { id: "models", tooltip: "Models", icon: CubeIcon }, + { id: "rules", tooltip: "Rules", icon: PencilIcon }, + { id: "docs", tooltip: "Docs", icon: BookOpenIcon }, + { id: "prompts", tooltip: "Prompts", icon: ChatBubbleLeftIcon }, + { id: "tools", tooltip: "Tools", icon: WrenchScrewdriverIcon }, + { id: "mcp", tooltip: "MCP", icon: Squares2X2Icon }, +]; + +function BlockSettingsToolbarIcon(props: BlockSettingsToolbarIcon) { + return ( + +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + props.onClick(); + } + }} + style={{ + backgroundColor: props.isSelected ? vscBadgeBackground : undefined, + }} + className={`relative flex select-none items-center rounded-full px-1 transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50`} + > +
+
+ ); +} + +interface BlockSettingsTopToolbarProps { + selectedSection: string | null; + setSelectedSection: (value: string | null) => void; +} + +export function BlockSettingsTopToolbar(props: BlockSettingsTopToolbarProps) { + const isExpanded = useAppSelector( + (state) => state.ui.isBlockSettingsToolbarExpanded, + ); + const dispatch = useAppDispatch(); + const handleEllipsisClick = () => { + if (isExpanded) { + props.setSelectedSection(null); + } + dispatch(toggleBlockSettingsToolbar()); + }; + + return ( +
+
+ +
+
+ {sections.map((section) => ( + + props.setSelectedSection( + props.selectedSection === section.id ? null : section.id, + ) + } + /> + ))} +
+
+
+
+ +
+
+ ); +} diff --git a/gui/src/components/mainInput/Lump/LumpToolbar.tsx b/gui/src/components/mainInput/Lump/LumpToolbar.tsx new file mode 100644 index 0000000000..cca7885d39 --- /dev/null +++ b/gui/src/components/mainInput/Lump/LumpToolbar.tsx @@ -0,0 +1,73 @@ +import { useContext } from "react"; +import styled from "styled-components"; +import { IdeMessengerContext } from "../../../context/IdeMessenger"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; +import { + clearLastEmptyResponse, + setInactive, +} from "../../../redux/slices/sessionSlice"; +import { getFontSize, getMetaKeyLabel } from "../../../util"; +import { BlockSettingsTopToolbar } from "./BlockSettingsTopToolbar"; + +const Container = styled.div` + display: flex; + justify-content: flex-end; + width: 100%; +`; + +const StopButton = styled.div` + font-size: ${getFontSize() - 3}px; + padding: 2px; + padding-right: 4px; + cursor: pointer; +`; + +interface TopToolbarProps { + selectedSection: string | null; + setSelectedSection: (value: string | null) => void; +} + +export function LumpToolbar(props: TopToolbarProps) { + const dispatch = useAppDispatch(); + const ideMessenger = useContext(IdeMessengerContext); + const ttsActive = useAppSelector((state) => state.ui.ttsActive); + const isStreaming = useAppSelector((state) => state.session.isStreaming); + + if (ttsActive) { + return ( + + { + ideMessenger.post("tts/kill", undefined); + }} + > + ■ Stop TTS + + + ); + } + + if (isStreaming) { + return ( + + { + dispatch(setInactive()); + dispatch(clearLastEmptyResponse()); + }} + > + {getMetaKeyLabel()} ⌫ Cancel + + + ); + } + + return ( + + ); +} diff --git a/gui/src/components/mainInput/Lump/index.tsx b/gui/src/components/mainInput/Lump/index.tsx new file mode 100644 index 0000000000..f46f432d09 --- /dev/null +++ b/gui/src/components/mainInput/Lump/index.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import styled from "styled-components"; +import { + defaultBorderRadius, + vscCommandCenterInactiveBorder, + vscInputBackground, +} from "../.."; +import { LumpToolbar } from "./LumpToolbar"; +import { SelectedSection } from "./sections/SelectedSection"; + +interface LumpProps { + open: boolean; + setOpen: (open: boolean) => void; +} + +const LumpDiv = styled.div<{ open: boolean }>` + background-color: ${vscInputBackground}; + margin-left: 4px; + margin-right: 4px; + border-radius: ${defaultBorderRadius} ${defaultBorderRadius} 0 0; + border-top: 1px solid ${vscCommandCenterInactiveBorder}; + border-left: 1px solid ${vscCommandCenterInactiveBorder}; + border-right: 1px solid ${vscCommandCenterInactiveBorder}; +`; + +const ContentDiv = styled.div<{ hasSection: boolean; isVisible: boolean }>` + transition: + max-height 0.3s ease-in-out, + margin 0.3s ease-in-out, + opacity 0.2s ease-in-out; + max-height: ${(props) => (props.hasSection ? "200px" : "0px")}; + margin: ${(props) => (props.hasSection ? "4px 0" : "0")}; + opacity: ${(props) => (props.isVisible ? "1" : "0")}; + overflow-y: auto; +`; + +export function Lump(props: LumpProps) { + const { open, setOpen } = props; + const [selectedSection, setSelectedSection] = useState(null); + const [displayedSection, setDisplayedSection] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (selectedSection) { + setDisplayedSection(selectedSection); + setIsVisible(true); + } else { + setIsVisible(false); + // Delay clearing the displayed section until after the fade-out + const timeout = setTimeout(() => { + setDisplayedSection(null); + }, 300); // Match the transition duration + return () => clearTimeout(timeout); + } + }, [selectedSection]); + + if (!open) { + return null; + } + + return ( + +
+ + + + + +
+
+ ); +} diff --git a/gui/src/components/mainInput/Lump/sections/AddBlockButton.tsx b/gui/src/components/mainInput/Lump/sections/AddBlockButton.tsx new file mode 100644 index 0000000000..b409d160fd --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/AddBlockButton.tsx @@ -0,0 +1,54 @@ +import { PlusIcon } from "@heroicons/react/24/outline"; +import { useContext } from "react"; +import { useAuth } from "../../../../context/Auth"; +import { IdeMessengerContext } from "../../../../context/IdeMessenger"; +import { useAppDispatch } from "../../../../redux/hooks"; +import { + setDialogMessage, + setShowDialog, +} from "../../../../redux/slices/uiSlice"; +import { fontSize } from "../../../../util"; +import AddDocsDialog from "../../../dialogs/AddDocsDialog"; + +export function AddBlockButton(props: { blockType: string }) { + const { selectedProfile } = useAuth(); + const ideMessenger = useContext(IdeMessengerContext); + const dispatch = useAppDispatch(); + + const handleClick = () => { + if (selectedProfile?.profileType === "local") { + switch (props.blockType) { + case "docs": + dispatch(setShowDialog(true)); + dispatch(setDialogMessage()); + break; + default: + ideMessenger.request("config/openProfile", { + profileId: selectedProfile.id, + }); + } + } else { + ideMessenger.request("controlPlane/openUrl", { + path: `new?type=block&blockType=${props.blockType}`, + orgSlug: undefined, + }); + } + }; + + return ( +
{ + e.preventDefault(); + handleClick(); + }} + > +
+ Add +
+
+ ); +} diff --git a/gui/src/components/mainInput/Lump/sections/ContextSection.tsx b/gui/src/components/mainInput/Lump/sections/ContextSection.tsx new file mode 100644 index 0000000000..7524fd08d8 --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/ContextSection.tsx @@ -0,0 +1,3 @@ +export function ContextSection() { + return
Context content
; +} diff --git a/gui/src/components/mainInput/Lump/sections/MCPSection.tsx b/gui/src/components/mainInput/Lump/sections/MCPSection.tsx new file mode 100644 index 0000000000..b365dca34f --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/MCPSection.tsx @@ -0,0 +1,5 @@ +import MCPServersPreview from "../../../../pages/config/MCPServersPreview"; + +export function MCPSection() { + return ; +} diff --git a/gui/src/components/mainInput/Lump/sections/ModelsSection.tsx b/gui/src/components/mainInput/Lump/sections/ModelsSection.tsx new file mode 100644 index 0000000000..cd16e84534 --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/ModelsSection.tsx @@ -0,0 +1,110 @@ +import { ModelRole } from "@continuedev/config-yaml"; +import { ModelDescription } from "core"; +import { useContext } from "react"; +import { useAuth } from "../../../../context/Auth"; +import { IdeMessengerContext } from "../../../../context/IdeMessenger"; +import ModelRoleSelector from "../../../../pages/config/ModelRoleSelector"; +import { useAppDispatch, useAppSelector } from "../../../../redux/hooks"; +import { + selectDefaultModel, + setDefaultModel, + updateConfig, +} from "../../../../redux/slices/configSlice"; +import { isJetBrains } from "../../../../util"; + +export function ModelsSection() { + const { selectedProfile } = useAuth(); + const dispatch = useAppDispatch(); + const ideMessenger = useContext(IdeMessengerContext); + + const config = useAppSelector((state) => state.config.config); + const jetbrains = isJetBrains(); + const selectedChatModel = useAppSelector(selectDefaultModel); + + function handleRoleUpdate(role: ModelRole, model: ModelDescription | null) { + if (!selectedProfile) { + return; + } + // Optimistic update + dispatch( + updateConfig({ + ...config, + selectedModelByRole: { + ...config.selectedModelByRole, + [role]: model, + }, + }), + ); + ideMessenger.post("config/updateSelectedModel", { + profileId: selectedProfile.id, + role, + title: model?.title ?? null, + }); + } + + // TODO use handleRoleUpdate for chat + function handleChatModelSelection(model: ModelDescription | null) { + if (!model) { + return; + } + dispatch(setDefaultModel({ title: model.title })); + } + + return ( +
+ handleChatModelSelection(model)} + /> + handleRoleUpdate("autocomplete", model)} + /> + {/* Jetbrains has a model selector inline */} + {!jetbrains && ( + handleRoleUpdate("edit", model)} + /> + )} + handleRoleUpdate("apply", model)} + /> + handleRoleUpdate("embed", model)} + /> + handleRoleUpdate("rerank", model)} + /> +
+ ); +} diff --git a/gui/src/components/mainInput/Lump/sections/PromptsSection.tsx b/gui/src/components/mainInput/Lump/sections/PromptsSection.tsx new file mode 100644 index 0000000000..38ce052091 --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/PromptsSection.tsx @@ -0,0 +1,80 @@ +import { + BookmarkIcon as BookmarkOutline, + PencilIcon, +} from "@heroicons/react/24/outline"; +import { BookmarkIcon as BookmarkSolid } from "@heroicons/react/24/solid"; +import { fontSize } from "../../../../util"; +import { useBookmarkedSlashCommands } from "../../../ConversationStarters/useBookmarkedSlashCommands"; +import { AddBlockButton } from "./AddBlockButton"; + +interface PromptRowProps { + command: string; + description: string; + isBookmarked: boolean; + setIsBookmarked: (isBookmarked: boolean) => void; + onEdit?: () => void; +} + +function PromptRow({ + command, + description, + isBookmarked, + setIsBookmarked, + onEdit, +}: PromptRowProps) { + return ( +
+
+ {command} + {description} +
+
+ +
setIsBookmarked(!isBookmarked)} + className="cursor-pointer pt-0.5 text-gray-400" + > + {isBookmarked ? ( + + ) : ( + + )} +
+
+
+ ); +} + +export function PromptsSection() { + const { cmdsSortedByBookmark, bookmarkStatuses, toggleBookmark } = + useBookmarkedSlashCommands(); + + const handleEdit = (prompt: any) => { + // Handle edit action here + console.log("Editing prompt:", prompt); + }; + + return ( +
+ {cmdsSortedByBookmark?.map((prompt, i) => ( + toggleBookmark(prompt)} + onEdit={() => handleEdit(prompt)} + /> + ))} + +
+ ); +} diff --git a/gui/src/pages/More/RulesPreview/index.tsx b/gui/src/components/mainInput/Lump/sections/RulesSection.tsx similarity index 72% rename from gui/src/pages/More/RulesPreview/index.tsx rename to gui/src/components/mainInput/Lump/sections/RulesSection.tsx index 6a5200a8a7..200472b22b 100644 --- a/gui/src/pages/More/RulesPreview/index.tsx +++ b/gui/src/components/mainInput/Lump/sections/RulesSection.tsx @@ -1,11 +1,14 @@ import { parseConfigYaml } from "@continuedev/config-yaml"; -import { PencilSquareIcon } from "@heroicons/react/24/outline"; +import { PencilIcon } from "@heroicons/react/24/outline"; import { useContext, useMemo } from "react"; import { useSelector } from "react-redux"; -import HeaderButtonWithToolTip from "../../../components/gui/HeaderButtonWithToolTip"; -import { useAuth } from "../../../context/Auth"; -import { IdeMessengerContext } from "../../../context/IdeMessenger"; -import { RootState } from "../../../redux/store"; +import { defaultBorderRadius, vscCommandCenterActiveBorder } from "../../.."; +import { useAuth } from "../../../../context/Auth"; +import { IdeMessengerContext } from "../../../../context/IdeMessenger"; +import { RootState } from "../../../../redux/store"; +import { fontSize } from "../../../../util"; +import HeaderButtonWithToolTip from "../../../gui/HeaderButtonWithToolTip"; +import { AddBlockButton } from "./AddBlockButton"; interface RuleCardProps { index: number; @@ -23,23 +26,42 @@ const RuleCard: React.FC = ({ index, rule, onClick, title }) => { }; return ( -
+
-
{title}
-
+
+ {title} +
+
{truncateRule(rule)}
- +
); }; -export function RulesPreview() { +export function RulesSection() { const ideMessenger = useContext(IdeMessengerContext); const { selectedProfile } = useAuth(); @@ -65,10 +87,6 @@ export function RulesPreview() { return (
-

- Assistant Rules -

- {mergedRules.length === 0 ? (

No rules defined yet

@@ -124,6 +142,7 @@ export function RulesPreview() { })}
)} +
); } diff --git a/gui/src/components/mainInput/Lump/sections/SelectedSection.tsx b/gui/src/components/mainInput/Lump/sections/SelectedSection.tsx new file mode 100644 index 0000000000..28dc37c28d --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/SelectedSection.tsx @@ -0,0 +1,32 @@ +import { ContextSection } from "./ContextSection"; +import DocsSection from "./docs/DocsSection"; +import { MCPSection } from "./MCPSection"; +import { ModelsSection } from "./ModelsSection"; +import { PromptsSection } from "./PromptsSection"; +import { RulesSection } from "./RulesSection"; +import { ToolsSection } from "./ToolsSection"; + +interface SelectedSectionProps { + selectedSection: string | null; +} + +export function SelectedSection(props: SelectedSectionProps) { + switch (props.selectedSection) { + case "models": + return ; + case "rules": + return ; + case "docs": + return ; + case "prompts": + return ; + case "context": + return ; + case "tools": + return ; + case "mcp": + return ; + default: + return null; + } +} diff --git a/gui/src/components/mainInput/Lump/sections/ToolsSection.tsx b/gui/src/components/mainInput/Lump/sections/ToolsSection.tsx new file mode 100644 index 0000000000..af03f72204 --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/ToolsSection.tsx @@ -0,0 +1,7 @@ +import { ToolPermissionsDialog } from "../../InputToolbar/bottom/ToolPermissionsDialog"; + +interface ToolsSectionProps {} + +export function ToolsSection({}: ToolsSectionProps) { + return ; +} diff --git a/gui/src/components/indexing/DocsDetailsDialog.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsDetailsDialog.tsx similarity index 84% rename from gui/src/components/indexing/DocsDetailsDialog.tsx rename to gui/src/components/mainInput/Lump/sections/docs/DocsDetailsDialog.tsx index f8d0a54e9d..2aa0698f09 100644 --- a/gui/src/components/indexing/DocsDetailsDialog.tsx +++ b/gui/src/components/mainInput/Lump/sections/docs/DocsDetailsDialog.tsx @@ -1,38 +1,15 @@ -import { - CheckIcon, - InformationCircleIcon, - PencilIcon, - PlusIcon, -} from "@heroicons/react/24/outline"; -import { - DocsIndexingDetails, - IndexingStatus, - PackageDocsResult, - SiteIndexingConfig, -} from "core"; -import preIndexedDocs from "core/indexing/docs/preIndexedDocs"; +import { DocsIndexingDetails } from "core"; import { usePostHog } from "posthog-js/react"; -import { - useCallback, - useContext, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useDispatch } from "react-redux"; -import { Input, SecondaryButton } from ".."; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { useAppSelector } from "../../redux/hooks"; -import { updateConfig } from "../../redux/slices/configSlice"; -import { updateIndexingStatus } from "../../redux/slices/indexingSlice"; -import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice"; -import FileIcon from "../FileIcon"; -import { ToolTip } from "../gui/Tooltip"; -import DocsIndexingPeeks from "../indexing/DocsIndexingPeeks"; import { Tooltip } from "react-tooltip"; - +import { SecondaryButton } from "../../../.."; +import { IdeMessengerContext } from "../../../../../context/IdeMessenger"; +import { useAppSelector } from "../../../../../redux/hooks"; +import { + setDialogMessage, + setShowDialog, +} from "../../../../../redux/slices/uiSlice"; interface DocsDetailsDialogProps { startUrl: string; } diff --git a/gui/src/components/indexing/DocsIndexingPeeks.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsIndexingPeeks.tsx similarity index 92% rename from gui/src/components/indexing/DocsIndexingPeeks.tsx rename to gui/src/components/mainInput/Lump/sections/docs/DocsIndexingPeeks.tsx index c218a5a1f1..bb2c75618e 100644 --- a/gui/src/components/indexing/DocsIndexingPeeks.tsx +++ b/gui/src/components/mainInput/Lump/sections/docs/DocsIndexingPeeks.tsx @@ -1,9 +1,11 @@ -import { useDispatch } from "react-redux"; import { IndexingStatus } from "core"; import { useMemo } from "react"; +import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; -import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice"; -import { useAppSelector } from "../../redux/hooks"; +import { + setDialogMessage, + setShowDialog, +} from "../../../../../redux/slices/uiSlice"; export interface DocsIndexingPeekProps { status: IndexingStatus; @@ -21,7 +23,7 @@ function DocsIndexingPeek({ status }: DocsIndexingPeekProps) {
{ - navigate("/more"); + // navigate("/more"); TODO dispatch(setShowDialog(false)); dispatch(setDialogMessage(undefined)); }} diff --git a/gui/src/components/mainInput/Lump/sections/docs/DocsIndexingStatus.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsIndexingStatus.tsx new file mode 100644 index 0000000000..28e764360f --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/docs/DocsIndexingStatus.tsx @@ -0,0 +1,188 @@ +import { ConfigYaml } from "@continuedev/config-yaml"; +import { + ArrowPathIcon, + ArrowTopRightOnSquareIcon, + PencilIcon, + StopIcon, +} from "@heroicons/react/24/outline"; +import { SiteIndexingConfig } from "core"; +import { useContext, useMemo, useState } from "react"; +import { useAuth } from "../../../../../context/Auth"; +import { IdeMessengerContext } from "../../../../../context/IdeMessenger"; +import { useAppDispatch, useAppSelector } from "../../../../../redux/hooks"; +import { updateIndexingStatus } from "../../../../../redux/slices/indexingSlice"; +import { + setDialogMessage, + setShowDialog, +} from "../../../../../redux/slices/uiSlice"; +import { fontSize } from "../../../../../util"; +import ConfirmationDialog from "../../../../dialogs/ConfirmationDialog"; +import { StatusIndicator } from "./StatusIndicator"; +interface IndexingStatusViewerProps { + docConfig: SiteIndexingConfig; + docFromYaml?: NonNullable[number]; +} + +function isUsesBlock(block: any): block is { uses: string } { + return typeof block !== "string" && "uses" in block; +} + +function DocsIndexingStatus({ + docConfig, + docFromYaml, +}: IndexingStatusViewerProps) { + const ideMessenger = useContext(IdeMessengerContext); + const { selectedProfile } = useAuth(); + const dispatch = useAppDispatch(); + + const status = useAppSelector( + (store) => store.indexing.indexing.statuses[docConfig.startUrl], + ); + + const reIndex = () => + ideMessenger.post("indexing/reindex", { + type: "docs", + id: docConfig.startUrl, + }); + + const abort = () => { + ideMessenger.post("indexing/abort", { + type: "docs", + id: docConfig.startUrl, + }); + // Optimistic abort status + if (status) { + dispatch( + updateIndexingStatus({ ...status, status: "aborted", progress: 0 }), + ); + } + }; + + const [hasDeleted, setHasDeleted] = useState(false); // simple alternative to optimistic redux update + const onDelete = () => { + // optimistic update + dispatch( + setDialogMessage( + { + ideMessenger.post("context/removeDocs", { + startUrl: docConfig.startUrl, + }); + setHasDeleted(true); + }} + />, + ), + ); + dispatch(setShowDialog(true)); + }; + + const openUrl = (path: string) => + ideMessenger.request("controlPlane/openUrl", { + path, + orgSlug: undefined, + }); + + const handleEdit = () => { + console.log("edit", docFromYaml); + if (selectedProfile?.profileType === "local") { + ideMessenger.post("config/openProfile", { + profileId: undefined, + }); + } else if (docFromYaml && isUsesBlock(docFromYaml)) { + openUrl(`${docFromYaml.uses}/new-version`); + } else if (selectedProfile?.fullSlug) { + const slug = `${selectedProfile.fullSlug.ownerSlug}/${selectedProfile.fullSlug.packageSlug}`; + openUrl(`${slug}/new-version`); + } + }; + const progressPercentage = useMemo(() => { + if (!status) { + return 0; + } + return Math.min(100, Math.max(0, status.progress * 100)).toFixed(0); + }, [status?.progress]); + + if (hasDeleted) return null; + + return ( +
+
+
{ + if (status?.url) { + ideMessenger.post("openUrl", status.url); + } + }} + > + + {docConfig.faviconUrl ? ( + doc icon + ) : null} +

+ {docConfig.title ?? docConfig.startUrl} +

+ +
+ +
+
+ {status?.status === "indexing" && ( + + {progressPercentage}% + + )} + + {status?.status === "indexing" && ( + + )} + + {["aborted", "complete", "failed"].includes( + status?.status ?? "", + ) && ( + + )} + + + + {/* Removed StatusIndicator from here */} +
+
+
+
+ ); +} + +export default DocsIndexingStatus; diff --git a/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx new file mode 100644 index 0000000000..4349c94981 --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx @@ -0,0 +1,78 @@ +import { parseConfigYaml } from "@continuedev/config-yaml"; +import { IndexingStatus } from "core"; +import { useMemo } from "react"; +import { useDispatch } from "react-redux"; +import { useAuth } from "../../../../../context/Auth"; +import { useAppSelector } from "../../../../../redux/hooks"; +import { AddBlockButton } from "../AddBlockButton"; +import DocsIndexingStatus from "./DocsIndexingStatus"; + +function DocsIndexingStatuses() { + const dispatch = useDispatch(); + const config = useAppSelector((store) => store.config.config); + const indexingStatuses = useAppSelector( + (store) => store.indexing.indexing.statuses, + ); + const { selectedProfile } = useAuth(); + + const mergedDocs = useMemo(() => { + const parsed = selectedProfile?.rawYaml + ? parseConfigYaml(selectedProfile?.rawYaml ?? "") + : undefined; + return (config.docs ?? []).map((doc, index) => ({ + doc, + docFromYaml: parsed?.docs?.[index], + })); + }, [config.docs, selectedProfile?.rawYaml]); + + const sortedConfigDocs = useMemo(() => { + const sorter = (status: IndexingStatus["status"]) => { + if (status === "complete") return 0; + if (status === "indexing" || status === "paused") return 1; + if (status === "failed") return 2; + if (status === "aborted" || status === "pending") return 3; + return 4; + }; + + const docs = [...mergedDocs]; + docs.sort((a, b) => { + const statusA = indexingStatuses[a.doc.startUrl]?.status ?? "pending"; + const statusB = indexingStatuses[b.doc.startUrl]?.status ?? "pending"; + + // First, compare by status + const statusCompare = sorter(statusA) - sorter(statusB); + if (statusCompare !== 0) return statusCompare; + + // If status is the same, sort by presence of icon + const hasIconA = !!a.doc.faviconUrl; + const hasIconB = !!b.doc.faviconUrl; + return hasIconB === hasIconA ? 0 : hasIconB ? 1 : -1; + }); + return docs; + }, [mergedDocs, indexingStatuses]); + + return ( +
+
+ {sortedConfigDocs.map((docConfig) => { + return ( +
+
+ +
+
+ ); + })} +
+ +
+ ); +} + +export default DocsIndexingStatuses; diff --git a/gui/src/components/mainInput/Lump/sections/docs/StatusIndicator.tsx b/gui/src/components/mainInput/Lump/sections/docs/StatusIndicator.tsx new file mode 100644 index 0000000000..d6dd2d55a3 --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/docs/StatusIndicator.tsx @@ -0,0 +1,44 @@ +import { IndexingStatus } from "core"; +import { ToolTip } from "../../../../gui/Tooltip"; + +interface StatusIndicatorProps { + status?: IndexingStatus["status"]; + className?: string; + size?: number; + hoverMessage?: string; +} + +const STATUS_TO_COLOR: Record = { + indexing: "bg-yellow-500", + paused: "bg-blue-500", + complete: "bg-green-500", + aborted: "bg-gray-500", + pending: "bg-gray-300", + failed: "bg-red-500", +}; + +export function StatusIndicator({ + status, + className = "", + size = 2, + hoverMessage, +}: StatusIndicatorProps) { + if (!status) return null; + + const indicator = ( +
+ ); + + return ( + <> + {indicator} + {hoverMessage && } + + ); +} diff --git a/gui/src/components/mainInput/TipTapEditor.tsx b/gui/src/components/mainInput/TipTapEditor.tsx deleted file mode 100644 index 06261d1b97..0000000000 --- a/gui/src/components/mainInput/TipTapEditor.tsx +++ /dev/null @@ -1,1035 +0,0 @@ -import Document from "@tiptap/extension-document"; -import History from "@tiptap/extension-history"; -import Image from "@tiptap/extension-image"; -import Paragraph from "@tiptap/extension-paragraph"; -import Placeholder from "@tiptap/extension-placeholder"; -import Text from "@tiptap/extension-text"; -import { Plugin } from "@tiptap/pm/state"; -import { Editor, EditorContent, JSONContent, useEditor } from "@tiptap/react"; -import { ContextProviderDescription, InputModifiers } from "core"; -import { rifWithContentsToContextItem } from "core/commands/util"; -import { modelSupportsImages } from "core/llm/autodetect"; -import { debounce } from "lodash"; -import { usePostHog } from "posthog-js/react"; -import { - KeyboardEvent, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; -import styled from "styled-components"; -import { - defaultBorderRadius, - lightGray, - vscBadgeBackground, - vscCommandCenterActiveBorder, - vscCommandCenterInactiveBorder, - vscForeground, - vscInputBackground, - vscInputBorderFocus, -} from ".."; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { useSubmenuContextProviders } from "../../context/SubmenuContextProviders"; -import { useInputHistory } from "../../hooks/useInputHistory"; -import useIsOSREnabled from "../../hooks/useIsOSREnabled"; -import useUpdatingRef from "../../hooks/useUpdatingRef"; -import { useWebviewListener } from "../../hooks/useWebviewListener"; -import { useAppDispatch, useAppSelector } from "../../redux/hooks"; -import { selectUseActiveFile } from "../../redux/selectors"; -import { selectDefaultModel } from "../../redux/slices/configSlice"; -import { - addCodeToEdit, - clearCodeToEdit, - selectHasCodeToEdit, - selectIsInEditMode, - setMainEditorContentTrigger, - setNewestCodeblocksForInput, -} from "../../redux/slices/sessionSlice"; -import { exitEditMode } from "../../redux/thunks"; -import { - loadLastSession, - loadSession, - saveCurrentSession, -} from "../../redux/thunks/session"; -import { - getFontSize, - isJetBrains, - isMetaEquivalentKeyPressed, -} from "../../util"; -import { AddCodeToEdit } from "./AddCodeToEditExtension"; -import { CodeBlockExtension } from "./CodeBlockExtension"; -import { SlashCommand } from "./CommandsExtension"; -import { MockExtension } from "./FillerExtension"; -import InputToolbar, { ToolbarOptions } from "./InputToolbar"; -import { Mention } from "./MentionExtension"; -import "./TipTapEditor.css"; -import { - getContextProviderDropdownOptions, - getSlashCommandDropdownOptions, -} from "./getSuggestion"; -import { - handleJetBrainsOSRMetaKeyIssues, - handleVSCMetaKeyIssues, -} from "./handleMetaKeyIssues"; -import { ComboBoxItem } from "./types"; - -const InputBoxDiv = styled.div<{}>` - resize: none; - padding-bottom: 4px; - font-family: inherit; - border-radius: ${defaultBorderRadius}; - margin: 0; - height: auto; - width: 100%; - background-color: ${vscInputBackground}; - color: ${vscForeground}; - - border: 1px solid ${vscCommandCenterInactiveBorder}; - transition: border-color 0.15s ease-in-out; - &:focus-within { - border: 1px solid ${vscCommandCenterActiveBorder}; - } - - outline: none; - font-size: ${getFontSize()}px; - - &:focus { - outline: none; - - border: 0.5px solid ${vscInputBorderFocus}; - } - - &::placeholder { - color: ${lightGray}cc; - } - - display: flex; - flex-direction: column; -`; - -const HoverDiv = styled.div` - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - opacity: 0.5; - background-color: ${vscBadgeBackground}; - color: ${vscForeground}; - display: flex; - align-items: center; - justify-content: center; -`; - -const HoverTextDiv = styled.div` - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - color: ${vscForeground}; - display: flex; - align-items: center; - justify-content: center; -`; - -const IMAGE_RESOLUTION = 1024; -function getDataUrlForFile( - file: File, - img: HTMLImageElement, -): string | undefined { - const targetWidth = IMAGE_RESOLUTION; - const targetHeight = IMAGE_RESOLUTION; - const scaleFactor = Math.min( - targetWidth / img.width, - targetHeight / img.height, - ); - - const canvas = document.createElement("canvas"); - canvas.width = img.width * scaleFactor; - canvas.height = img.height * scaleFactor; - - const ctx = canvas.getContext("2d"); - if (!ctx) { - console.error("Error getting image data url: 2d context not found"); - return; - } - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - - const downsizedDataUrl = canvas.toDataURL("image/jpeg", 0.7); - return downsizedDataUrl; -} - -interface TipTapEditorProps { - availableContextProviders: ContextProviderDescription[]; - availableSlashCommands: ComboBoxItem[]; - isMainInput: boolean; - onEnter: ( - editorState: JSONContent, - modifiers: InputModifiers, - editor: Editor, - ) => void; - editorState?: JSONContent; - toolbarOptions?: ToolbarOptions; - placeholder?: string; - historyKey: string; - inputId: string; -} - -export const TIPPY_DIV_ID = "tippy-js-div"; - -function TipTapEditor(props: TipTapEditorProps) { - const dispatch = useAppDispatch(); - - const ideMessenger = useContext(IdeMessengerContext); - const { getSubmenuContextItems } = useSubmenuContextProviders(); - - const historyLength = useAppSelector((store) => store.session.history.length); - - const useActiveFile = useAppSelector(selectUseActiveFile); - - const posthog = usePostHog(); - - const inSubmenuRef = useRef(undefined); - const inDropdownRef = useRef(false); - - const isOSREnabled = useIsOSREnabled(); - - const enterSubmenu = async (editor: Editor, providerId: string) => { - const contents = editor.getText(); - const indexOfAt = contents.lastIndexOf("@"); - if (indexOfAt === -1) { - return; - } - - // Find the position of the last @ character - // We do this because editor.getText() isn't a correct representation including node views - let startPos = editor.state.selection.anchor; - while ( - startPos > 0 && - editor.state.doc.textBetween(startPos, startPos + 1) !== "@" - ) { - startPos--; - } - startPos++; - - editor.commands.deleteRange({ - from: startPos, - to: editor.state.selection.anchor, - }); - inSubmenuRef.current = providerId; - - // to trigger refresh of suggestions - editor.commands.insertContent(":"); - editor.commands.deleteRange({ - from: editor.state.selection.anchor - 1, - to: editor.state.selection.anchor, - }); - }; - - const onClose = () => { - inSubmenuRef.current = undefined; - inDropdownRef.current = false; - }; - - const onOpen = () => { - inDropdownRef.current = true; - }; - - const defaultModel = useAppSelector(selectDefaultModel); - const defaultModelRef = useUpdatingRef(defaultModel); - - const getSubmenuContextItemsRef = useUpdatingRef(getSubmenuContextItems); - const availableContextProvidersRef = useUpdatingRef( - props.availableContextProviders, - ); - - const historyLengthRef = useUpdatingRef(historyLength); - const availableSlashCommandsRef = useUpdatingRef( - props.availableSlashCommands, - ); - - const isStreaming = useAppSelector((state) => state.session.isStreaming); - const isStreamingRef = useUpdatingRef(isStreaming); - - const isInEditMode = useAppSelector(selectIsInEditMode); - const isInEditModeRef = useUpdatingRef(isInEditMode); - const hasCodeToEdit = useAppSelector(selectHasCodeToEdit); - const isEditModeAndNoCodeToEdit = isInEditMode && !hasCodeToEdit; - async function handleImageFile( - file: File, - ): Promise<[HTMLImageElement, string] | undefined> { - let filesize = file.size / 1024 / 1024; // filesize in MB - // check image type and size - if ( - [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/svg", - "image/webp", - ].includes(file.type) && - filesize < 10 - ) { - // check dimensions - let _URL = window.URL || window.webkitURL; - let img = new window.Image(); - img.src = _URL.createObjectURL(file); - - return await new Promise((resolve) => { - img.onload = function () { - const dataUrl = getDataUrlForFile(file, img); - if (!dataUrl) { - return; - } - - let image = new window.Image(); - image.src = dataUrl; - image.onload = function () { - resolve([image, dataUrl]); - }; - }; - }); - } else { - ideMessenger.post("showToast", [ - "error", - "Images need to be in jpg or png format and less than 10MB in size.", - ]); - } - } - - const { prevRef, nextRef, addRef } = useInputHistory(props.historyKey); - - const editor: Editor | null = useEditor({ - extensions: [ - Document, - History, - Image.extend({ - addProseMirrorPlugins() { - const plugin = new Plugin({ - props: { - handleDOMEvents: { - paste(view, event) { - const model = defaultModelRef.current; - if (!model) return; - const items = event.clipboardData?.items; - if (items) { - for (const item of items) { - const file = item.getAsFile(); - file && - modelSupportsImages( - model.provider, - model.model, - model.title, - model.capabilities, - ) && - handleImageFile(file).then((resp) => { - if (!resp) return; - const [img, dataUrl] = resp; - const { schema } = view.state; - const node = schema.nodes.image.create({ - src: dataUrl, - }); - const tr = view.state.tr.insert(0, node); - view.dispatch(tr); - }); - } - } - }, - }, - }, - }); - return [plugin]; - }, - }).configure({ - HTMLAttributes: { - class: "object-contain max-h-[210px] max-w-full mx-1", - }, - }), - Placeholder.configure({ - placeholder: getPlaceholderText( - props.placeholder, - historyLengthRef.current, - ), - }), - Paragraph.extend({ - addKeyboardShortcuts() { - return { - Enter: () => { - if (inDropdownRef.current) { - return false; - } - - onEnterRef.current({ - useCodebase: false, - noContext: !useActiveFile, - }); - return true; - }, - - "Mod-Enter": () => { - onEnterRef.current({ - useCodebase: true, - noContext: !useActiveFile, - }); - return true; - }, - "Alt-Enter": () => { - posthog.capture("gui_use_active_file_enter"); - - onEnterRef.current({ - useCodebase: false, - noContext: !!useActiveFile, - }); - - return true; - }, - "Mod-Backspace": () => { - // If you press cmd+backspace wanting to cancel, - // but are inside of a text box, it shouldn't - // delete the text - if (isStreamingRef.current) { - return true; - } - return false; - }, - "Shift-Enter": () => - this.editor.commands.first(({ commands }) => [ - () => commands.newlineInCode(), - () => commands.createParagraphNear(), - () => commands.liftEmptyBlock(), - () => commands.splitBlock(), - ]), - - ArrowUp: () => { - if (this.editor.state.selection.anchor > 1) { - return false; - } - - const previousInput = prevRef.current( - this.editor.state.toJSON().doc, - ); - if (previousInput) { - this.editor.commands.setContent(previousInput); - setTimeout(() => { - this.editor.commands.blur(); - this.editor.commands.focus("start"); - }, 0); - return true; - } - return false; - }, - Escape: () => { - if (inDropdownRef.current || !isInEditModeRef.current) { - ideMessenger.post("focusEditor", undefined); - return true; - } - (async () => { - await dispatch( - loadLastSession({ - saveCurrentSession: false, - }), - ); - dispatch(exitEditMode()); - })(); - - return true; - }, - ArrowDown: () => { - if ( - this.editor.state.selection.anchor < - this.editor.state.doc.content.size - 1 - ) { - return false; - } - const nextInput = nextRef.current(); - if (nextInput) { - this.editor.commands.setContent(nextInput); - setTimeout(() => { - this.editor.commands.blur(); - this.editor.commands.focus("end"); - }, 0); - return true; - } - return false; - }, - }; - }, - }).configure({ - HTMLAttributes: { - class: "my-1", - }, - }), - Text, - Mention.configure({ - HTMLAttributes: { - class: "mention", - }, - suggestion: getContextProviderDropdownOptions( - availableContextProvidersRef, - getSubmenuContextItemsRef, - enterSubmenu, - onClose, - onOpen, - inSubmenuRef, - ideMessenger, - ), - renderHTML: (props) => { - return `@${props.node.attrs.label || props.node.attrs.id}`; - }, - }), - - AddCodeToEdit.configure({ - HTMLAttributes: { - class: "add-code-to-edit", - }, - suggestion: { - ...getContextProviderDropdownOptions( - availableContextProvidersRef, - getSubmenuContextItemsRef, - enterSubmenu, - onClose, - onOpen, - inSubmenuRef, - ideMessenger, - ), - allow: () => isInEditModeRef.current, - command: async ({ editor, range, props }) => { - editor.chain().focus().insertContentAt(range, "").run(); - const filepath = props.id; - const contents = await ideMessenger.ide.readFile(filepath); - dispatch( - addCodeToEdit({ - filepath, - contents, - }), - ); - }, - items: async ({ query }) => { - // Only display files in the dropdown - const results = getSubmenuContextItemsRef.current("file", query); - return results.map((result) => ({ - ...result, - label: result.title, - type: "file", - query: result.id, - icon: result.icon, - })); - }, - }, - }), - props.availableSlashCommands.length - ? SlashCommand.configure({ - HTMLAttributes: { - class: "mention", - }, - suggestion: getSlashCommandDropdownOptions( - availableSlashCommandsRef, - onClose, - onOpen, - ideMessenger, - ), - renderText: (props) => { - return props.node.attrs.label; - }, - }) - : MockExtension, - CodeBlockExtension, - ], - editorProps: { - attributes: { - class: "outline-none -mt-1 overflow-hidden", - style: `font-size: ${getFontSize()}px;`, - }, - }, - content: props.editorState, - editable: !isStreaming || props.isMainInput, - }); - - const [shouldHideToolbar, setShouldHideToolbar] = useState(false); - const debouncedShouldHideToolbar = debounce((value) => { - setShouldHideToolbar(value); - }, 200); - - function getPlaceholderText( - placeholder: TipTapEditorProps["placeholder"], - historyLength: number, - ) { - if (placeholder) { - return placeholder; - } - - return historyLength === 0 - ? "Ask anything, '/' for prompts, '@' to add context" - : "Ask a follow-up"; - } - - useEffect(() => { - if (!editor) { - return; - } - const placeholder = getPlaceholderText( - props.placeholder, - historyLengthRef.current, - ); - - editor.extensionManager.extensions.filter( - (extension) => extension.name === "placeholder", - )[0].options["placeholder"] = placeholder; - - editor.view.dispatch(editor.state.tr); - }, [editor, props.placeholder, historyLengthRef.current]); - - useEffect(() => { - if (props.isMainInput) { - editor?.commands.clearContent(true); - } - }, [editor, isInEditMode, props.isMainInput]); - - useEffect(() => { - if (editor) { - const handleFocus = () => { - setShouldHideToolbar(false); - }; - - const handleBlur = () => { - // TODO - make toolbar auto-hiding work without breaking tool dropdown focus - // debouncedShouldHideToolbar(true); - }; - - editor.on("focus", handleFocus); - editor.on("blur", handleBlur); - - return () => { - editor.off("focus", handleFocus); - editor.off("blur", handleBlur); - }; - } - }, [editor]); - - const editorFocusedRef = useUpdatingRef(editor?.isFocused, [editor]); - - /** - * This handles various issues with meta key actions - * - In JetBrains, when using OSR in JCEF, there is a bug where using the meta key to - * highlight code using arrow keys is not working - * - In VS Code, while working with .ipynb files there is a problem where copy/paste/cut will affect - * the actual notebook cells, even when performing them in our GUI - * - * Currently keydown events for a number of keys are not registering if the - * meta/shift key is pressed, for example "x", "c", "v", "z", etc. - * Until this is resolved we can't turn on OSR for non-Mac users due to issues - * with those key actions. - */ - const handleKeyDown = async (e: KeyboardEvent) => { - if (!editor) { - return; - } - - setActiveKey(e.key); - - if (!editorFocusedRef?.current || !isMetaEquivalentKeyPressed(e)) return; - - if (isOSREnabled) { - handleJetBrainsOSRMetaKeyIssues(e, editor); - } else if (!isJetBrains()) { - await handleVSCMetaKeyIssues(e, editor); - } - }; - - const handleKeyUp = () => { - setActiveKey(null); - }; - - const onEnterRef = useUpdatingRef( - (modifiers: InputModifiers) => { - if (!editor) { - return; - } - if (isStreaming || isEditModeAndNoCodeToEdit) { - return; - } - - const json = editor.getJSON(); - - // Don't do anything if input box is empty - if (!json.content?.some((c) => c.content)) { - return; - } - - if (props.isMainInput) { - addRef.current(json); - } - - props.onEnter(json, modifiers, editor); - }, - [props.onEnter, editor, props.isMainInput], - ); - - useEffect(() => { - if (props.isMainInput) { - /** - * I have a strong suspicion that many of the other focus - * commands are redundant, especially the ones inside - * useTimeout. - */ - editor?.commands.focus(); - } - }, [props.isMainInput, editor]); - - // Re-focus main input after done generating - useEffect(() => { - if (editor && !isStreaming && props.isMainInput && document.hasFocus()) { - editor.commands.focus(undefined, { scrollIntoView: false }); - } - }, [props.isMainInput, isStreaming, editor]); - - // This allows anywhere in the app to set the content of the main input - const mainInputContentTrigger = useAppSelector( - (store) => store.session.mainEditorContentTrigger, - ); - useEffect(() => { - if (!props.isMainInput || !mainInputContentTrigger) { - return; - } - queueMicrotask(() => { - editor?.commands.setContent(mainInputContentTrigger); - }); - dispatch(setMainEditorContentTrigger(undefined)); - }, [editor, props.isMainInput, mainInputContentTrigger]); - - // IDE event listeners - useWebviewListener( - "userInput", - async (data) => { - if (!props.isMainInput) { - return; - } - editor?.commands.insertContent(data.input); - onEnterRef.current({ useCodebase: false, noContext: true }); - }, - [editor, onEnterRef.current, props.isMainInput], - ); - - useWebviewListener("jetbrains/editorInsetRefresh", async () => { - editor?.chain().clearContent().focus().run(); - }); - - useWebviewListener( - "focusContinueInput", - async (data) => { - if (!props.isMainInput) { - return; - } - - dispatch(clearCodeToEdit()); - - if (historyLength > 0) { - await dispatch( - saveCurrentSession({ - openNewSession: false, - generateTitle: true, - }), - ); - } - setTimeout(() => { - editor?.commands.blur(); - editor?.commands.focus("end"); - }, 20); - }, - [historyLength, editor, props.isMainInput], - ); - - useWebviewListener( - "focusContinueInputWithoutClear", - async () => { - if (!props.isMainInput) { - return; - } - setTimeout(() => { - editor?.commands.focus("end"); - }, 20); - }, - [editor, props.isMainInput], - ); - - useWebviewListener( - "focusContinueInputWithNewSession", - async () => { - if (!props.isMainInput) { - return; - } - await dispatch( - saveCurrentSession({ - openNewSession: true, - generateTitle: true, - }), - ); - setTimeout(() => { - editor?.commands.focus("end"); - }, 20); - }, - [editor, props.isMainInput], - ); - - useWebviewListener( - "highlightedCode", - async (data) => { - if (!props.isMainInput || !editor) { - return; - } - - const contextItem = rifWithContentsToContextItem( - data.rangeInFileWithContents, - ); - - let index = 0; - for (const el of editor.getJSON()?.content ?? []) { - if (el.attrs?.item?.name === contextItem.name) { - return; // Prevent exact duplicate code blocks - } - if (el.type === "codeBlock") { - index += 2; - } else { - break; - } - } - editor - .chain() - .insertContentAt(index, { - type: "codeBlock", - attrs: { - item: contextItem, - inputId: props.inputId, - }, - }) - .run(); - dispatch( - setNewestCodeblocksForInput({ - inputId: props.inputId, - contextItemId: contextItem.id.itemId, - }), - ); - if (data.prompt) { - editor.commands.focus("end"); - editor.commands.insertContent(data.prompt); - } - - if (data.shouldRun) { - onEnterRef.current({ useCodebase: false, noContext: true }); - } - - setTimeout(() => { - editor.commands.blur(); - editor.commands.focus("end"); - }, 20); - }, - [editor, props.isMainInput, historyLength, onEnterRef.current], - ); - - useWebviewListener( - "focusEdit", - async () => { - if (!props.isMainInput) { - return; - } - - setTimeout(() => { - editor?.commands.focus("end"); - }, 20); - }, - [editor, props.isMainInput], - ); - - useWebviewListener( - "focusEditWithoutClear", - async () => { - if (!props.isMainInput) { - return; - } - - setTimeout(() => { - editor?.commands.focus("end"); - }, 2000); - }, - [editor, props.isMainInput], - ); - - useWebviewListener( - "isContinueInputFocused", - async () => { - return props.isMainInput && !!editorFocusedRef.current; - }, - [editorFocusedRef, props.isMainInput], - !props.isMainInput, - ); - - useWebviewListener( - "focusContinueSessionId", - async (data) => { - if (!props.isMainInput || !data.sessionId) { - return; - } - await dispatch( - loadSession({ - sessionId: data.sessionId, - saveCurrentSession: true, - }), - ); - }, - [props.isMainInput], - ); - - const [showDragOverMsg, setShowDragOverMsg] = useState(false); - - useEffect(() => { - const overListener = (event: DragEvent) => { - if (event.shiftKey) return; - setShowDragOverMsg(true); - }; - window.addEventListener("dragover", overListener); - - const leaveListener = (event: DragEvent) => { - if (event.shiftKey) { - setShowDragOverMsg(false); - } else { - setTimeout(() => setShowDragOverMsg(false), 2000); - } - }; - window.addEventListener("dragleave", leaveListener); - - return () => { - window.removeEventListener("dragover", overListener); - window.removeEventListener("dragleave", leaveListener); - }; - }, []); - - const [activeKey, setActiveKey] = useState(null); - - const insertCharacterWithWhitespace = useCallback( - (char: string) => { - if (!editor) { - return; - } - const text = editor.getText(); - if (!text.endsWith(char)) { - if (text.length > 0 && !text.endsWith(" ")) { - editor.commands.insertContent(` ${char}`); - } else { - editor.commands.insertContent(char); - } - } - }, - [editor], - ); - - return ( - { - editor?.commands.focus(); - }} - onDragOver={(event) => { - event.preventDefault(); - setShowDragOverMsg(true); - }} - onDragLeave={(e) => { - if (e.relatedTarget === null) { - if (e.shiftKey) { - setShowDragOverMsg(false); - } else { - setTimeout(() => setShowDragOverMsg(false), 2000); - } - } - }} - onDragEnter={() => { - setShowDragOverMsg(true); - }} - onDrop={(event) => { - if ( - !defaultModel || - !modelSupportsImages( - defaultModel.provider, - defaultModel.model, - defaultModel.title, - defaultModel.capabilities, - ) - ) { - return; - } - setShowDragOverMsg(false); - let file = event.dataTransfer.files[0]; - handleImageFile(file).then((result) => { - if (!editor) { - return; - } - if (result) { - const [_, dataUrl] = result; - const { schema } = editor.state; - const node = schema.nodes.image.create({ src: dataUrl }); - const tr = editor.state.tr.insert(0, node); - editor.view.dispatch(tr); - } - }); - event.preventDefault(); - }} - > -
- { - event.stopPropagation(); - }} - /> -
- - {showDragOverMsg && - modelSupportsImages( - defaultModel?.provider || "", - defaultModel?.model || "", - defaultModel?.title, - defaultModel?.capabilities, - ) && ( - <> - - Hold ⇧ to drop image - - )} -
- - ); -} - -export default TipTapEditor; diff --git a/gui/src/components/mainInput/ContextItemsPeek.tsx b/gui/src/components/mainInput/belowMainInput/ContextItemsPeek.tsx similarity index 93% rename from gui/src/components/mainInput/ContextItemsPeek.tsx rename to gui/src/components/mainInput/belowMainInput/ContextItemsPeek.tsx index 9cd140d2c9..eb63e0ee9d 100644 --- a/gui/src/components/mainInput/ContextItemsPeek.tsx +++ b/gui/src/components/mainInput/belowMainInput/ContextItemsPeek.tsx @@ -7,14 +7,14 @@ import { ContextItemWithId } from "core"; import { ctxItemToRifWithContents } from "core/commands/util"; import { getUriPathBasename } from "core/util/uri"; import { useContext, useMemo, useState } from "react"; -import { AnimatedEllipsis, lightGray, vscBackground } from ".."; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { useAppSelector } from "../../redux/hooks"; -import { selectIsGatheringContext } from "../../redux/slices/sessionSlice"; -import FileIcon from "../FileIcon"; -import SafeImg from "../SafeImg"; -import { getIconFromDropdownItem } from "./MentionList"; -import { NAMED_ICONS } from "./icons"; +import { AnimatedEllipsis, lightGray, vscBackground } from "../.."; +import { IdeMessengerContext } from "../../../context/IdeMessenger"; +import { useAppSelector } from "../../../redux/hooks"; +import { selectIsGatheringContext } from "../../../redux/slices/sessionSlice"; +import FileIcon from "../../FileIcon"; +import SafeImg from "../../SafeImg"; +import { getIconFromDropdownItem } from "../AtMentionDropdown"; +import { NAMED_ICONS } from "../icons"; interface ContextItemsPeekProps { contextItems?: ContextItemWithId[]; diff --git a/gui/src/components/mainInput/NewSessionButton.tsx b/gui/src/components/mainInput/belowMainInput/NewSessionButton.tsx similarity index 90% rename from gui/src/components/mainInput/NewSessionButton.tsx rename to gui/src/components/mainInput/belowMainInput/NewSessionButton.tsx index 04193450ca..d8623af968 100644 --- a/gui/src/components/mainInput/NewSessionButton.tsx +++ b/gui/src/components/mainInput/belowMainInput/NewSessionButton.tsx @@ -1,6 +1,6 @@ import styled from "styled-components"; -import { defaultBorderRadius, lightGray, vscForeground } from ".."; -import { getFontSize } from "../../util"; +import { defaultBorderRadius, lightGray, vscForeground } from "../.."; +import { getFontSize } from "../../../util"; export const NewSessionButton = styled.div` width: fit-content; diff --git a/gui/src/components/mainInput/ThinkingBlockPeek.tsx b/gui/src/components/mainInput/belowMainInput/ThinkingBlockPeek.tsx similarity index 97% rename from gui/src/components/mainInput/ThinkingBlockPeek.tsx rename to gui/src/components/mainInput/belowMainInput/ThinkingBlockPeek.tsx index 8516352300..a89ba2e6a1 100644 --- a/gui/src/components/mainInput/ThinkingBlockPeek.tsx +++ b/gui/src/components/mainInput/belowMainInput/ThinkingBlockPeek.tsx @@ -4,9 +4,9 @@ import { ChevronUpIcon } from "@heroicons/react/24/solid"; import { ChatHistoryItem } from "core"; import { useEffect, useState } from "react"; import styled from "styled-components"; -import { defaultBorderRadius, lightGray, vscBackground } from ".."; -import { getFontSize } from "../../util"; -import StyledMarkdownPreview from "../markdown/StyledMarkdownPreview"; +import { defaultBorderRadius, lightGray, vscBackground } from "../.."; +import { getFontSize } from "../../../util"; +import StyledMarkdownPreview from "../../markdown/StyledMarkdownPreview"; const SpoilerButton = styled.div` background-color: ${vscBackground}; diff --git a/gui/src/components/mainInput/CodeBlockComponent.tsx b/gui/src/components/mainInput/tiptap/CodeBlockComponent.tsx similarity index 92% rename from gui/src/components/mainInput/CodeBlockComponent.tsx rename to gui/src/components/mainInput/tiptap/CodeBlockComponent.tsx index 61403a2610..e63e273ada 100644 --- a/gui/src/components/mainInput/CodeBlockComponent.tsx +++ b/gui/src/components/mainInput/tiptap/CodeBlockComponent.tsx @@ -1,7 +1,7 @@ import { NodeViewWrapper, NodeViewWrapperProps } from "@tiptap/react"; import { ContextItemWithId } from "core"; -import { vscBadgeBackground } from ".."; -import CodeSnippetPreview from "../markdown/CodeSnippetPreview"; +import { vscBadgeBackground } from "../.."; +import CodeSnippetPreview from "../../markdown/CodeSnippetPreview"; export const CodeBlockComponent = (props: any) => { const { node, deleteNode, selected, editor, updateAttributes } = props; diff --git a/gui/src/components/mainInput/tiptap/DragOverlay.tsx b/gui/src/components/mainInput/tiptap/DragOverlay.tsx new file mode 100644 index 0000000000..1417a82281 --- /dev/null +++ b/gui/src/components/mainInput/tiptap/DragOverlay.tsx @@ -0,0 +1,40 @@ +import React, { useEffect } from "react"; +import { HoverDiv, HoverTextDiv } from "./StyledComponents"; + +interface DragOverlayProps { + show: boolean; + setShow: (show: boolean) => void; +} + +export const DragOverlay: React.FC = ({ show, setShow }) => { + useEffect(() => { + const overListener = (event: DragEvent) => { + if (event.shiftKey) return; + setShow(true); + }; + window.addEventListener("dragover", overListener); + + const leaveListener = (event: DragEvent) => { + if (event.shiftKey) { + setShow(false); + } else { + setTimeout(() => setShow(false), 2000); + } + }; + window.addEventListener("dragleave", leaveListener); + + return () => { + window.removeEventListener("dragover", overListener); + window.removeEventListener("dragleave", leaveListener); + }; + }, []); + + if (!show) return null; + + return ( + <> + + Hold ⇧ to drop image + + ); +}; diff --git a/gui/src/components/mainInput/tiptap/StyledComponents.ts b/gui/src/components/mainInput/tiptap/StyledComponents.ts new file mode 100644 index 0000000000..2aca4f1be2 --- /dev/null +++ b/gui/src/components/mainInput/tiptap/StyledComponents.ts @@ -0,0 +1,72 @@ +import styled from "styled-components"; +import { + defaultBorderRadius, + lightGray, + vscBadgeBackground, + vscCommandCenterActiveBorder, + vscCommandCenterInactiveBorder, + vscForeground, + vscInputBackground, + vscInputBorderFocus, +} from "../.."; +import { getFontSize } from "../../../util"; + +export const InputBoxDiv = styled.div<{}>` + resize: none; + font-family: inherit; + border-radius: ${defaultBorderRadius}; + padding-bottom: 1px; + margin: 0; + height: auto; + width: 100%; + background-color: ${vscInputBackground}; + color: ${vscForeground}; + + border: 1px solid ${vscCommandCenterInactiveBorder}; + transition: border-color 0.15s ease-in-out; + &:focus-within { + border: 1px solid ${vscCommandCenterActiveBorder}; + } + + outline: none; + font-size: ${getFontSize()}px; + + &:focus { + outline: none; + + border: 0.5px solid ${vscInputBorderFocus}; + } + + &::placeholder { + color: ${lightGray}cc; + } + + display: flex; + flex-direction: column; +`; + +export const HoverDiv = styled.div` + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + opacity: 0.5; + background-color: ${vscBadgeBackground}; + color: ${vscForeground}; + display: flex; + align-items: center; + justify-content: center; +`; + +export const HoverTextDiv = styled.div` + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + color: ${vscForeground}; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/gui/src/components/mainInput/TipTapEditor.css b/gui/src/components/mainInput/tiptap/TipTapEditor.css similarity index 100% rename from gui/src/components/mainInput/TipTapEditor.css rename to gui/src/components/mainInput/tiptap/TipTapEditor.css diff --git a/gui/src/components/mainInput/tiptap/TipTapEditor.tsx b/gui/src/components/mainInput/tiptap/TipTapEditor.tsx new file mode 100644 index 0000000000..722cb007c9 --- /dev/null +++ b/gui/src/components/mainInput/tiptap/TipTapEditor.tsx @@ -0,0 +1,283 @@ +import { Editor, EditorContent, JSONContent } from "@tiptap/react"; +import { ContextProviderDescription, InputModifiers } from "core"; +import { modelSupportsImages } from "core/llm/autodetect"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { IdeMessengerContext } from "../../../context/IdeMessenger"; +import useIsOSREnabled from "../../../hooks/useIsOSREnabled"; +import useUpdatingRef from "../../../hooks/useUpdatingRef"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; +import { selectDefaultModel } from "../../../redux/slices/configSlice"; +import { + selectIsInEditMode, + setMainEditorContentTrigger, +} from "../../../redux/slices/sessionSlice"; +import InputToolbar, { ToolbarOptions } from "../InputToolbar"; +import { ComboBoxItem } from "../types"; +import { DragOverlay } from "./DragOverlay"; +import { createEditorConfig, getPlaceholderText } from "./editorConfig"; +import { handleImageFile } from "./imageUtils"; +import { useEditorEventHandlers } from "./keyHandlers"; +import { InputBoxDiv } from "./StyledComponents"; +import "./TipTapEditor.css"; +import { useWebviewListeners } from "./useWebviewListeners"; + +export interface TipTapEditorProps { + availableContextProviders: ContextProviderDescription[]; + availableSlashCommands: ComboBoxItem[]; + isMainInput: boolean; + onEnter: ( + editorState: JSONContent, + modifiers: InputModifiers, + editor: Editor, + ) => void; + editorState?: JSONContent; + toolbarOptions?: ToolbarOptions; + lumpOpen: boolean; + setLumpOpen: (open: boolean) => void; + placeholder?: string; + historyKey: string; + inputId: string; +} + +export const TIPPY_DIV_ID = "tippy-js-div"; + +function TipTapEditor(props: TipTapEditorProps) { + const dispatch = useAppDispatch(); + + const ideMessenger = useContext(IdeMessengerContext); + + const isOSREnabled = useIsOSREnabled(); + + const defaultModel = useAppSelector(selectDefaultModel); + const isStreaming = useAppSelector((state) => state.session.isStreaming); + const isInEditMode = useAppSelector(selectIsInEditMode); + const historyLength = useAppSelector((store) => store.session.history.length); + + const { editor, onEnterRef } = createEditorConfig({ + props, + ideMessenger, + dispatch, + }); + + const [shouldHideToolbar, setShouldHideToolbar] = useState(true); + + useEffect(() => { + if (!editor) { + return; + } + const placeholder = getPlaceholderText(props.placeholder, historyLength); + + editor.extensionManager.extensions.filter( + (extension) => extension.name === "placeholder", + )[0].options["placeholder"] = placeholder; + + editor.view.dispatch(editor.state.tr); + }, [editor, props.placeholder, historyLength]); + + useEffect(() => { + if (props.isMainInput) { + editor?.commands.clearContent(true); + } + }, [editor, isInEditMode, props.isMainInput]); + + const editorFocusedRef = useUpdatingRef(editor?.isFocused, [editor]); + + useEffect(() => { + if (props.isMainInput) { + /** + * I have a strong suspicion that many of the other focus + * commands are redundant, especially the ones inside + * useTimeout. + */ + editor?.commands.focus(); + } + }, [props.isMainInput, editor]); + + // Re-focus main input after done generating + useEffect(() => { + if (editor && !isStreaming && props.isMainInput && document.hasFocus()) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }, [props.isMainInput, isStreaming, editor]); + + // This allows anywhere in the app to set the content of the main input + const mainInputContentTrigger = useAppSelector( + (store) => store.session.mainEditorContentTrigger, + ); + useEffect(() => { + if (!props.isMainInput || !mainInputContentTrigger) { + return; + } + queueMicrotask(() => { + editor?.commands.setContent(mainInputContentTrigger); + }); + dispatch(setMainEditorContentTrigger(undefined)); + }, [editor, props.isMainInput, mainInputContentTrigger]); + + // IDE event listeners + useWebviewListeners({ + editor, + onEnterRef, + dispatch, + historyLength, + props, + editorFocusedRef, + }); + + const [showDragOverMsg, setShowDragOverMsg] = useState(false); + + const [activeKey, setActiveKey] = useState(null); + + const insertCharacterWithWhitespace = useCallback( + (char: string) => { + if (!editor) { + return; + } + const text = editor.getText(); + if (!text.endsWith(char)) { + if (text.length > 0 && !text.endsWith(" ")) { + editor.commands.insertContent(` ${char}`); + } else { + editor.commands.insertContent(char); + } + } + }, + [editor], + ); + + const { handleKeyUp, handleKeyDown } = useEditorEventHandlers({ + editor, + isOSREnabled: isOSREnabled, + editorFocusedRef, + isInEditMode, + setActiveKey, + }); + + const handleBlur = (e: React.FocusEvent) => { + // Check if the new focus target is within our InputBoxDiv + const currentTarget = e.currentTarget; + const relatedTarget = e.relatedTarget as Node | null; + + if (relatedTarget && currentTarget.contains(relatedTarget)) { + return; + } + + setShouldHideToolbar(true); + }; + + return ( + { + setShouldHideToolbar(false); + }} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + className={shouldHideToolbar ? "cursor-default" : "cursor-text"} + onClick={() => { + editor?.commands.focus(); + }} + onDragOver={(event) => { + event.preventDefault(); + setShowDragOverMsg(true); + }} + onDragLeave={(e) => { + if (e.relatedTarget === null) { + if (e.shiftKey) { + setShowDragOverMsg(false); + } else { + setTimeout(() => setShowDragOverMsg(false), 2000); + } + } + }} + onDragEnter={() => { + setShowDragOverMsg(true); + }} + onDrop={(event) => { + if ( + !defaultModel || + !modelSupportsImages( + defaultModel.provider, + defaultModel.model, + defaultModel.title, + defaultModel.capabilities, + ) + ) { + return; + } + setShowDragOverMsg(false); + let file = event.dataTransfer.files[0]; + handleImageFile(ideMessenger, file).then((result) => { + if (!editor) { + return; + } + if (result) { + const [_, dataUrl] = result; + const { schema } = editor.state; + const node = schema.nodes.image.create({ src: dataUrl }); + const tr = editor.state.tr.insert(0, node); + editor.view.dispatch(tr); + } + }); + event.preventDefault(); + }} + > +
+ {/* */} + { + event.stopPropagation(); + }} + /> + {(shouldHideToolbar && !props.isMainInput) || ( +
+ + {showDragOverMsg && + modelSupportsImages( + defaultModel?.provider || "", + defaultModel?.model || "", + defaultModel?.title, + defaultModel?.capabilities, + ) && ( + + )} +
+ + ); +} + +export default TipTapEditor; diff --git a/gui/src/components/mainInput/tiptap/editorConfig.ts b/gui/src/components/mainInput/tiptap/editorConfig.ts new file mode 100644 index 0000000000..d79981d398 --- /dev/null +++ b/gui/src/components/mainInput/tiptap/editorConfig.ts @@ -0,0 +1,409 @@ +import { Editor } from "@tiptap/core"; +import Document from "@tiptap/extension-document"; +import History from "@tiptap/extension-history"; +import Image from "@tiptap/extension-image"; +import Paragraph from "@tiptap/extension-paragraph"; +import Placeholder from "@tiptap/extension-placeholder"; +import Text from "@tiptap/extension-text"; +import { Plugin } from "@tiptap/pm/state"; +import { useEditor } from "@tiptap/react"; +import { InputModifiers } from "core"; +import { modelSupportsImages } from "core/llm/autodetect"; +import { usePostHog } from "posthog-js/react"; +import { useRef } from "react"; +import { IIdeMessenger } from "../../../context/IdeMessenger"; +import { useSubmenuContextProviders } from "../../../context/SubmenuContextProviders"; +import { useInputHistory } from "../../../hooks/useInputHistory"; +import useUpdatingRef from "../../../hooks/useUpdatingRef"; +import { useAppSelector } from "../../../redux/hooks"; +import { selectUseActiveFile } from "../../../redux/selectors"; +import { selectDefaultModel } from "../../../redux/slices/configSlice"; +import { + addCodeToEdit, + selectHasCodeToEdit, + selectIsInEditMode, +} from "../../../redux/slices/sessionSlice"; +import { AppDispatch } from "../../../redux/store"; +import { exitEditMode } from "../../../redux/thunks"; +import { loadLastSession } from "../../../redux/thunks/session"; +import { getFontSize } from "../../../util"; +import { AddCodeToEdit } from "./extensions/AddCodeToEditExtension"; +import { CodeBlockExtension } from "./extensions/CodeBlockExtension"; +import { SlashCommand } from "./extensions/CommandsExtension"; +import { MockExtension } from "./extensions/FillerExtension"; +import { Mention } from "./extensions/MentionExtension"; +import { + getContextProviderDropdownOptions, + getSlashCommandDropdownOptions, +} from "./getSuggestion"; +import { handleImageFile } from "./imageUtils"; +import { TipTapEditorProps } from "./TipTapEditor"; + +export function getPlaceholderText( + placeholder: TipTapEditorProps["placeholder"], + historyLength: number, +) { + if (placeholder) { + return placeholder; + } + + return historyLength === 0 + ? "Ask anything, '/' for prompts, '@' to add context" + : "Ask a follow-up"; +} + +/** + * This function is called only once, so we need to use refs to pass in the latest values + */ +export function createEditorConfig(options: { + props: TipTapEditorProps; + ideMessenger: IIdeMessenger; + dispatch: AppDispatch; +}) { + const { props, ideMessenger, dispatch } = options; + + const posthog = usePostHog(); + + // #region Selectors + const { getSubmenuContextItems } = useSubmenuContextProviders(); + const defaultModel = useAppSelector(selectDefaultModel); + const isStreaming = useAppSelector((state) => state.session.isStreaming); + const useActiveFile = useAppSelector(selectUseActiveFile); + const historyLength = useAppSelector((store) => store.session.history.length); + const isInEditMode = useAppSelector(selectIsInEditMode); + const hasCodeToEdit = useAppSelector(selectHasCodeToEdit); + const isEditModeAndNoCodeToEdit = isInEditMode && !hasCodeToEdit; + // #endregion + + // #region Refs + const inSubmenuRef = useRef(undefined); + const inDropdownRef = useRef(false); + const defaultModelRef = useUpdatingRef(defaultModel); + const isStreamingRef = useUpdatingRef(isStreaming); + const isInEditModeRef = useUpdatingRef(isInEditMode); + const getSubmenuContextItemsRef = useUpdatingRef(getSubmenuContextItems); + const availableContextProvidersRef = useUpdatingRef( + props.availableContextProviders, + ); + const historyLengthRef = useUpdatingRef(historyLength); + const availableSlashCommandsRef = useUpdatingRef( + props.availableSlashCommands, + ); + const { prevRef, nextRef, addRef } = useInputHistory(props.historyKey); + + // #endregion + + const enterSubmenu = async (editor: Editor, providerId: string) => { + const contents = editor.getText(); + const indexOfAt = contents.lastIndexOf("@"); + if (indexOfAt === -1) { + return; + } + + // Find the position of the last @ character + // We do this because editor.getText() isn't a correct representation including node views + let startPos = editor.state.selection.anchor; + while ( + startPos > 0 && + editor.state.doc.textBetween(startPos, startPos + 1) !== "@" + ) { + startPos--; + } + startPos++; + + editor.commands.deleteRange({ + from: startPos, + to: editor.state.selection.anchor, + }); + inSubmenuRef.current = providerId; + + // to trigger refresh of suggestions + editor.commands.insertContent(":"); + editor.commands.deleteRange({ + from: editor.state.selection.anchor - 1, + to: editor.state.selection.anchor, + }); + }; + + const onClose = () => { + inSubmenuRef.current = undefined; + inDropdownRef.current = false; + }; + + const onOpen = () => { + inDropdownRef.current = true; + }; + + const editor: Editor | null = useEditor({ + extensions: [ + Document, + History, + Image.extend({ + addProseMirrorPlugins() { + const plugin = new Plugin({ + props: { + handleDOMEvents: { + paste(view, event) { + const model = defaultModelRef.current; + if (!model) return; + const items = event.clipboardData?.items; + if (items) { + for (const item of items) { + const file = item.getAsFile(); + file && + modelSupportsImages( + model.provider, + model.model, + model.title, + model.capabilities, + ) && + handleImageFile(ideMessenger, file).then((resp) => { + if (!resp) return; + const [img, dataUrl] = resp; + const { schema } = view.state; + const node = schema.nodes.image.create({ + src: dataUrl, + }); + const tr = view.state.tr.insert(0, node); + view.dispatch(tr); + }); + } + } + }, + }, + }, + }); + return [plugin]; + }, + }).configure({ + HTMLAttributes: { + class: "object-contain max-h-[210px] max-w-full mx-1", + }, + }), + Placeholder.configure({ + placeholder: getPlaceholderText( + props.placeholder, + historyLengthRef.current, + ), + }), + Paragraph.extend({ + addKeyboardShortcuts() { + return { + Enter: () => { + if (inDropdownRef.current) { + return false; + } + + onEnterRef.current({ + useCodebase: false, + noContext: !useActiveFile, + }); + return true; + }, + + "Mod-Enter": () => { + onEnterRef.current({ + useCodebase: true, + noContext: !useActiveFile, + }); + return true; + }, + "Alt-Enter": () => { + posthog.capture("gui_use_active_file_enter"); + + onEnterRef.current({ + useCodebase: false, + noContext: !!useActiveFile, + }); + + return true; + }, + "Mod-Backspace": () => { + // If you press cmd+backspace wanting to cancel, + // but are inside of a text box, it shouldn't + // delete the text + if (isStreamingRef.current) { + return true; + } + return false; + }, + "Shift-Enter": () => + this.editor.commands.first(({ commands }) => [ + () => commands.newlineInCode(), + () => commands.createParagraphNear(), + () => commands.liftEmptyBlock(), + () => commands.splitBlock(), + ]), + + ArrowUp: () => { + if (this.editor.state.selection.anchor > 1) { + return false; + } + + const previousInput = prevRef.current( + this.editor.state.toJSON().doc, + ); + if (previousInput) { + this.editor.commands.setContent(previousInput); + setTimeout(() => { + this.editor.commands.blur(); + this.editor.commands.focus("start"); + }, 0); + return true; + } + return false; + }, + Escape: () => { + if (inDropdownRef.current || !isInEditModeRef.current) { + ideMessenger.post("focusEditor", undefined); + return true; + } + (async () => { + await dispatch( + loadLastSession({ + saveCurrentSession: false, + }), + ); + dispatch(exitEditMode()); + })(); + + return true; + }, + ArrowDown: () => { + if ( + this.editor.state.selection.anchor < + this.editor.state.doc.content.size - 1 + ) { + return false; + } + const nextInput = nextRef.current(); + if (nextInput) { + this.editor.commands.setContent(nextInput); + setTimeout(() => { + this.editor.commands.blur(); + this.editor.commands.focus("end"); + }, 0); + return true; + } + return false; + }, + }; + }, + }).configure({ + HTMLAttributes: { + class: "my-1", + }, + }), + Text, + Mention.configure({ + HTMLAttributes: { + class: "mention", + }, + suggestion: getContextProviderDropdownOptions( + availableContextProvidersRef, + getSubmenuContextItemsRef, + enterSubmenu, + onClose, + onOpen, + inSubmenuRef, + ideMessenger, + ), + renderHTML: (props) => { + return `@${props.node.attrs.label || props.node.attrs.id}`; + }, + }), + + AddCodeToEdit.configure({ + HTMLAttributes: { + class: "add-code-to-edit", + }, + suggestion: { + ...getContextProviderDropdownOptions( + availableContextProvidersRef, + getSubmenuContextItemsRef, + enterSubmenu, + onClose, + onOpen, + inSubmenuRef, + ideMessenger, + ), + allow: () => isInEditModeRef.current, + command: async ({ editor, range, props }) => { + editor.chain().focus().insertContentAt(range, "").run(); + const filepath = props.id; + const contents = await ideMessenger.ide.readFile(filepath); + dispatch( + addCodeToEdit({ + filepath, + contents, + }), + ); + }, + items: async ({ query }) => { + // Only display files in the dropdown + const results = getSubmenuContextItemsRef.current("file", query); + return results.map((result) => ({ + ...result, + label: result.title, + type: "file", + query: result.id, + icon: result.icon, + })); + }, + }, + }), + props.availableSlashCommands.length + ? SlashCommand.configure({ + HTMLAttributes: { + class: "mention", + }, + suggestion: getSlashCommandDropdownOptions( + availableSlashCommandsRef, + onClose, + onOpen, + ideMessenger, + ), + renderText: (props) => { + return props.node.attrs.label; + }, + }) + : MockExtension, + CodeBlockExtension, + ], + editorProps: { + attributes: { + class: "outline-none -mt-1 overflow-hidden", + style: `font-size: ${getFontSize()}px;`, + }, + }, + content: props.editorState, + editable: !isStreaming || props.isMainInput, + }); + + const onEnterRef = useUpdatingRef( + (modifiers: InputModifiers) => { + if (!editor) { + return; + } + if (isStreaming || isEditModeAndNoCodeToEdit) { + return; + } + + const json = editor.getJSON(); + + // Don't do anything if input box is empty + if (!json.content?.some((c) => c.content)) { + return; + } + + if (props.isMainInput) { + addRef.current(json); + } + + props.onEnter(json, modifiers, editor); + }, + [props.onEnter, editor, props.isMainInput], + ); + + return { editor, onEnterRef }; +} diff --git a/gui/src/components/mainInput/AddCodeToEditExtension.ts b/gui/src/components/mainInput/tiptap/extensions/AddCodeToEditExtension.ts similarity index 100% rename from gui/src/components/mainInput/AddCodeToEditExtension.ts rename to gui/src/components/mainInput/tiptap/extensions/AddCodeToEditExtension.ts diff --git a/gui/src/components/mainInput/CodeBlockExtension.tsx b/gui/src/components/mainInput/tiptap/extensions/CodeBlockExtension.tsx similarity index 91% rename from gui/src/components/mainInput/CodeBlockExtension.tsx rename to gui/src/components/mainInput/tiptap/extensions/CodeBlockExtension.tsx index 091694db91..aea6e18af0 100644 --- a/gui/src/components/mainInput/CodeBlockExtension.tsx +++ b/gui/src/components/mainInput/tiptap/extensions/CodeBlockExtension.tsx @@ -1,6 +1,6 @@ import { mergeAttributes, Node } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { CodeBlockComponent } from "./CodeBlockComponent"; +import { CodeBlockComponent } from "../CodeBlockComponent"; export const CodeBlockExtension = Node.create({ name: "codeBlock", diff --git a/gui/src/components/mainInput/CommandsExtension.ts b/gui/src/components/mainInput/tiptap/extensions/CommandsExtension.ts similarity index 100% rename from gui/src/components/mainInput/CommandsExtension.ts rename to gui/src/components/mainInput/tiptap/extensions/CommandsExtension.ts diff --git a/gui/src/components/mainInput/FillerExtension.tsx b/gui/src/components/mainInput/tiptap/extensions/FillerExtension.tsx similarity index 100% rename from gui/src/components/mainInput/FillerExtension.tsx rename to gui/src/components/mainInput/tiptap/extensions/FillerExtension.tsx diff --git a/gui/src/components/mainInput/MentionExtension.ts b/gui/src/components/mainInput/tiptap/extensions/MentionExtension.ts similarity index 100% rename from gui/src/components/mainInput/MentionExtension.ts rename to gui/src/components/mainInput/tiptap/extensions/MentionExtension.ts diff --git a/gui/src/components/mainInput/getSuggestion.ts b/gui/src/components/mainInput/tiptap/getSuggestion.ts similarity index 97% rename from gui/src/components/mainInput/getSuggestion.ts rename to gui/src/components/mainInput/tiptap/getSuggestion.ts index ab107f1463..27963aab9f 100644 --- a/gui/src/components/mainInput/getSuggestion.ts +++ b/gui/src/components/mainInput/tiptap/getSuggestion.ts @@ -6,10 +6,10 @@ import { } from "core"; import { MutableRefObject } from "react"; import tippy from "tippy.js"; -import { IIdeMessenger } from "../../context/IdeMessenger"; -import MentionList from "./MentionList"; +import { IIdeMessenger } from "../../../context/IdeMessenger"; +import AtMentionDropdown from "../AtMentionDropdown"; +import { ComboBoxItem, ComboBoxItemType, ComboBoxSubAction } from "../types"; import { TIPPY_DIV_ID } from "./TipTapEditor"; -import { ComboBoxItem, ComboBoxItemType, ComboBoxSubAction } from "./types"; function getSuggestion( items: (props: { query: string }) => Promise, @@ -32,7 +32,7 @@ function getSuggestion( return { onStart: (props: any) => { - component = new ReactRenderer(MentionList, { + component = new ReactRenderer(AtMentionDropdown, { props: { ...props, enterSubmenu, onClose: onExit }, editor: props.editor, }); diff --git a/gui/src/components/mainInput/tiptap/imageUtils.ts b/gui/src/components/mainInput/tiptap/imageUtils.ts new file mode 100644 index 0000000000..0803e5ab8b --- /dev/null +++ b/gui/src/components/mainInput/tiptap/imageUtils.ts @@ -0,0 +1,73 @@ +import { IIdeMessenger } from "../../../context/IdeMessenger"; + +const IMAGE_RESOLUTION = 1024; + +export function getDataUrlForFile( + file: File, + img: HTMLImageElement, +): string | undefined { + const targetWidth = IMAGE_RESOLUTION; + const targetHeight = IMAGE_RESOLUTION; + const scaleFactor = Math.min( + targetWidth / img.width, + targetHeight / img.height, + ); + + const canvas = document.createElement("canvas"); + canvas.width = img.width * scaleFactor; + canvas.height = img.height * scaleFactor; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.error("Error getting image data url: 2d context not found"); + return; + } + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + const downsizedDataUrl = canvas.toDataURL("image/jpeg", 0.7); + return downsizedDataUrl; +} + +export async function handleImageFile( + ideMessenger: IIdeMessenger, + file: File, +): Promise<[HTMLImageElement, string] | undefined> { + let filesize = file.size / 1024 / 1024; // filesize in MB + // check image type and size + if ( + [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/svg", + "image/webp", + ].includes(file.type) && + filesize < 10 + ) { + // check dimensions + let _URL = window.URL || window.webkitURL; + let img = new window.Image(); + img.src = _URL.createObjectURL(file); + + return await new Promise((resolve) => { + img.onload = function () { + const dataUrl = getDataUrlForFile(file, img); + if (!dataUrl) { + return; + } + + let image = new window.Image(); + image.src = dataUrl; + image.onload = function () { + resolve([image, dataUrl]); + }; + }; + }); + } else { + ideMessenger.post("showToast", [ + "error", + "Images need to be in jpg or png format and less than 10MB in size.", + ]); + } +} diff --git a/gui/src/components/mainInput/tiptap/keyHandlers.ts b/gui/src/components/mainInput/tiptap/keyHandlers.ts new file mode 100644 index 0000000000..c25a53271f --- /dev/null +++ b/gui/src/components/mainInput/tiptap/keyHandlers.ts @@ -0,0 +1,50 @@ +import { Editor } from "@tiptap/react"; +import { KeyboardEvent } from "react"; +import { isJetBrains, isMetaEquivalentKeyPressed } from "../../../util"; +import { + handleJetBrainsOSRMetaKeyIssues, + handleVSCMetaKeyIssues, +} from "../util/handleMetaKeyIssues"; + +export function useEditorEventHandlers(options: { + editor: Editor | null; + isOSREnabled: boolean; + editorFocusedRef: React.MutableRefObject; + isInEditMode: boolean; + setActiveKey: (key: string | null) => void; +}) { + const { editor, isOSREnabled, editorFocusedRef, isInEditMode, setActiveKey } = + options; + + /** + * This handles various issues with meta key actions + * - In JetBrains, when using OSR in JCEF, there is a bug where using the meta key to + * highlight code using arrow keys is not working + * - In VS Code, while working with .ipynb files there is a problem where copy/paste/cut will affect + * the actual notebook cells, even when performing them in our GUI + * + * Currently keydown events for a number of keys are not registering if the + * meta/shift key is pressed, for example "x", "c", "v", "z", etc. + * Until this is resolved we can't turn on OSR for non-Mac users due to issues + * with those key actions. + */ + const handleKeyDown = async (e: KeyboardEvent) => { + if (!editor) { + return; + } + + setActiveKey(e.key); + + if (!editorFocusedRef?.current || !isMetaEquivalentKeyPressed(e)) return; + + if (isOSREnabled) { + handleJetBrainsOSRMetaKeyIssues(e, editor); + } else if (!isJetBrains()) { + await handleVSCMetaKeyIssues(e, editor); + } + }; + + const handleKeyUp = () => setActiveKey(null); + + return { handleKeyDown, handleKeyUp }; +} diff --git a/gui/src/components/mainInput/resolveInput.ts b/gui/src/components/mainInput/tiptap/resolveInput.ts similarity index 98% rename from gui/src/components/mainInput/resolveInput.ts rename to gui/src/components/mainInput/tiptap/resolveInput.ts index 1113d2fc38..d9eb47b6c7 100644 --- a/gui/src/components/mainInput/resolveInput.ts +++ b/gui/src/components/mainInput/tiptap/resolveInput.ts @@ -1,3 +1,4 @@ +import { Dispatch } from "@reduxjs/toolkit"; import { JSONContent } from "@tiptap/react"; import { ContextItemWithId, @@ -8,12 +9,11 @@ import { RangeInFile, TextMessagePart, } from "core"; -import { stripImages } from "core/util/messageContent"; -import { IIdeMessenger } from "../../context/IdeMessenger"; -import { Dispatch } from "@reduxjs/toolkit"; -import { setIsGatheringContext } from "../../redux/slices/sessionSlice"; import { ctxItemToRifWithContents } from "core/commands/util"; +import { stripImages } from "core/util/messageContent"; import { getUriFileExtension } from "core/util/uri"; +import { IIdeMessenger } from "../../../context/IdeMessenger"; +import { setIsGatheringContext } from "../../../redux/slices/sessionSlice"; interface MentionAttrs { label: string; diff --git a/gui/src/components/mainInput/tiptap/useWebviewListeners.ts b/gui/src/components/mainInput/tiptap/useWebviewListeners.ts new file mode 100644 index 0000000000..3a46949395 --- /dev/null +++ b/gui/src/components/mainInput/tiptap/useWebviewListeners.ts @@ -0,0 +1,211 @@ +import { Editor } from "@tiptap/core"; +import { InputModifiers } from "core"; +import { rifWithContentsToContextItem } from "core/commands/util"; +import { MutableRefObject } from "react"; +import { useWebviewListener } from "../../../hooks/useWebviewListener"; +import { + clearCodeToEdit, + setNewestCodeblocksForInput, +} from "../../../redux/slices/sessionSlice"; +import { AppDispatch } from "../../../redux/store"; +import { loadSession, saveCurrentSession } from "../../../redux/thunks/session"; +import { TipTapEditorProps } from "./TipTapEditor"; + +export function useWebviewListeners(options: { + editor: Editor | null; + onEnterRef: MutableRefObject<(modifiers: InputModifiers) => void>; + dispatch: AppDispatch; + historyLength: number; + props: TipTapEditorProps; + editorFocusedRef: MutableRefObject; +}) { + const { + editor, + onEnterRef, + dispatch, + historyLength, + props, + editorFocusedRef, + } = options; + + useWebviewListener( + "userInput", + async (data) => { + if (!props.isMainInput) { + return; + } + editor?.commands.insertContent(data.input); + onEnterRef.current({ useCodebase: false, noContext: true }); + }, + [editor, onEnterRef.current, props.isMainInput], + ); + + useWebviewListener("jetbrains/editorInsetRefresh", async () => { + editor?.chain().clearContent().focus().run(); + }); + + useWebviewListener( + "focusContinueInput", + async (data) => { + if (!props.isMainInput) { + return; + } + + dispatch(clearCodeToEdit()); + + if (historyLength > 0) { + await dispatch( + saveCurrentSession({ + openNewSession: false, + generateTitle: true, + }), + ); + } + setTimeout(() => { + editor?.commands.blur(); + editor?.commands.focus("end"); + }, 20); + }, + [historyLength, editor, props.isMainInput], + ); + + useWebviewListener( + "focusContinueInputWithoutClear", + async () => { + if (!props.isMainInput) { + return; + } + setTimeout(() => { + editor?.commands.focus("end"); + }, 20); + }, + [editor, props.isMainInput], + ); + + useWebviewListener( + "focusContinueInputWithNewSession", + async () => { + if (!props.isMainInput) { + return; + } + await dispatch( + saveCurrentSession({ + openNewSession: true, + generateTitle: true, + }), + ); + setTimeout(() => { + editor?.commands.focus("end"); + }, 20); + }, + [editor, props.isMainInput], + ); + + useWebviewListener( + "highlightedCode", + async (data) => { + if (!props.isMainInput || !editor) { + return; + } + + const contextItem = rifWithContentsToContextItem( + data.rangeInFileWithContents, + ); + + let index = 0; + for (const el of editor.getJSON()?.content ?? []) { + if (el.attrs?.item?.name === contextItem.name) { + return; // Prevent exact duplicate code blocks + } + if (el.type === "codeBlock") { + index += 2; + } else { + break; + } + } + editor + .chain() + .insertContentAt(index, { + type: "codeBlock", + attrs: { + item: contextItem, + inputId: props.inputId, + }, + }) + .run(); + dispatch( + setNewestCodeblocksForInput({ + inputId: props.inputId, + contextItemId: contextItem.id.itemId, + }), + ); + if (data.prompt) { + editor.commands.focus("end"); + editor.commands.insertContent(data.prompt); + } + + if (data.shouldRun) { + onEnterRef.current({ useCodebase: false, noContext: true }); + } + + setTimeout(() => { + editor.commands.blur(); + editor.commands.focus("end"); + }, 20); + }, + [editor, props.isMainInput, historyLength, onEnterRef.current], + ); + + useWebviewListener( + "focusEdit", + async () => { + if (!props.isMainInput) { + return; + } + + setTimeout(() => { + editor?.commands.focus("end"); + }, 20); + }, + [editor, props.isMainInput], + ); + + useWebviewListener( + "focusEditWithoutClear", + async () => { + if (!props.isMainInput) { + return; + } + + setTimeout(() => { + editor?.commands.focus("end"); + }, 2000); + }, + [editor, props.isMainInput], + ); + + useWebviewListener( + "isContinueInputFocused", + async () => { + return props.isMainInput && !!editorFocusedRef.current; + }, + [editorFocusedRef, props.isMainInput], + !props.isMainInput, + ); + + useWebviewListener( + "focusContinueSessionId", + async (data) => { + if (!props.isMainInput || !data.sessionId) { + return; + } + await dispatch( + loadSession({ + sessionId: data.sessionId, + saveCurrentSession: true, + }), + ); + }, + [props.isMainInput], + ); +} diff --git a/gui/src/components/mainInput/handleMetaKeyIssues.ts b/gui/src/components/mainInput/util/handleMetaKeyIssues.ts similarity index 98% rename from gui/src/components/mainInput/handleMetaKeyIssues.ts rename to gui/src/components/mainInput/util/handleMetaKeyIssues.ts index 1adf5593a8..9f9078303e 100644 --- a/gui/src/components/mainInput/handleMetaKeyIssues.ts +++ b/gui/src/components/mainInput/util/handleMetaKeyIssues.ts @@ -1,6 +1,6 @@ import { Editor } from "@tiptap/react"; import { KeyboardEvent } from "react"; -import { getPlatform, isWebEnvironment } from "../../util"; +import { getPlatform, isWebEnvironment } from "../../../util"; const isWebEnv = isWebEnvironment(); diff --git a/gui/src/components/mainInput/inputModifiers.ts b/gui/src/components/mainInput/util/inputModifiers.ts similarity index 100% rename from gui/src/components/mainInput/inputModifiers.ts rename to gui/src/components/mainInput/util/inputModifiers.ts diff --git a/gui/src/components/markdown/StepContainerPreActionButtons.tsx b/gui/src/components/markdown/StepContainerPreActionButtons.tsx index 5cb33d9397..85ece0990f 100644 --- a/gui/src/components/markdown/StepContainerPreActionButtons.tsx +++ b/gui/src/components/markdown/StepContainerPreActionButtons.tsx @@ -1,22 +1,26 @@ -import { useContext, useRef, useState } from "react"; import { + ArrowLeftEndOnRectangleIcon, CommandLineIcon, PlayIcon, - ArrowLeftEndOnRectangleIcon, } from "@heroicons/react/24/outline"; -import { defaultBorderRadius, vscEditorBackground } from ".."; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { isJetBrains } from "../../util"; -import { isTerminalCodeBlock, getTerminalCommand } from "./utils"; -import HeaderButtonWithToolTip from "../gui/HeaderButtonWithToolTip"; -import { CopyIconButton } from "../gui/CopyIconButton"; +import { useContext, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; +import { + defaultBorderRadius, + vscCommandCenterInactiveBorder, + vscEditorBackground, +} from ".."; +import { IdeMessengerContext } from "../../context/IdeMessenger"; import { useWebviewListener } from "../../hooks/useWebviewListener"; import { useAppSelector } from "../../redux/hooks"; import { selectDefaultModel, selectUIConfig, } from "../../redux/slices/configSlice"; +import { isJetBrains } from "../../util"; +import { CopyIconButton } from "../gui/CopyIconButton"; +import HeaderButtonWithToolTip from "../gui/HeaderButtonWithToolTip"; +import { getTerminalCommand, isTerminalCodeBlock } from "./utils"; interface StepContainerPreActionButtonsProps { language: string | null; @@ -84,14 +88,20 @@ export default function StepContainerPreActionButtons({ tabIndex={-1} onMouseEnter={() => setHovering(true)} onMouseLeave={() => setHovering(false)} - className="bg-vsc-editor-background border-vsc-input-border relative my-2.5 rounded-md border-[1px] border-solid" + className="bg-vsc-editor-background relative my-2.5" + style={{ + border: `1px solid ${vscCommandCenterInactiveBorder}`, + borderRadius: defaultBorderRadius, + }} >
{children}
{hovering && !isStreaming && (
{shouldRunTerminalCmd && ( diff --git a/gui/src/components/markdown/StepContainerPreToolbar/StepContainerPreToolbar.tsx b/gui/src/components/markdown/StepContainerPreToolbar/StepContainerPreToolbar.tsx index 99e449cc7f..46fa90d683 100644 --- a/gui/src/components/markdown/StepContainerPreToolbar/StepContainerPreToolbar.tsx +++ b/gui/src/components/markdown/StepContainerPreToolbar/StepContainerPreToolbar.tsx @@ -4,7 +4,11 @@ import { debounce } from "lodash"; import { useContext, useEffect, useRef, useState } from "react"; import styled from "styled-components"; import { v4 as uuidv4 } from "uuid"; -import { lightGray, vscEditorBackground } from "../.."; +import { + defaultBorderRadius, + vscCommandCenterInactiveBorder, + vscEditorBackground, +} from "../.."; import { IdeMessengerContext } from "../../../context/IdeMessenger"; import { useWebviewListener } from "../../../hooks/useWebviewListener"; import { useAppSelector } from "../../../redux/hooks"; @@ -22,9 +26,9 @@ import GeneratingCodeLoader from "./GeneratingCodeLoader"; import RunInTerminalButton from "./RunInTerminalButton"; const TopDiv = styled.div` - outline: 0.5px solid rgba(153, 153, 152); + outline: 1px solid ${vscCommandCenterInactiveBorder}; outline-offset: -0.5px; - border-radius: 2.5px; + border-radius: ${defaultBorderRadius}; margin-bottom: 8px !important; background-color: ${vscEditorBackground}; min-width: 0; @@ -39,7 +43,7 @@ const ToolbarDiv = styled.div<{ isExpanded: boolean }>` padding: 4px 6px; margin: 0; border-bottom: ${({ isExpanded }) => - isExpanded ? `0.5px solid ${lightGray}80` : "inherit"}; + isExpanded ? `1px solid ${vscCommandCenterInactiveBorder}` : "inherit"}; `; export interface StepContainerPreToolbarProps { @@ -225,7 +229,7 @@ export default function StepContainerPreToolbar( {isExpanded && (
{props.children} diff --git a/gui/src/components/modelSelection/ModeSelect.tsx b/gui/src/components/modelSelection/ModeSelect.tsx new file mode 100644 index 0000000000..afc0588fa2 --- /dev/null +++ b/gui/src/components/modelSelection/ModeSelect.tsx @@ -0,0 +1,183 @@ +// A dropdown menu for selecting between Chat, Edit, and Agent modes with keyboard shortcuts +import { Listbox } from "@headlessui/react"; +import { + ChatBubbleLeftIcon, + CheckIcon, + ChevronDownIcon, + PencilIcon, + SparklesIcon, +} from "@heroicons/react/24/outline"; +import { MessageModes } from "core"; +import { modelSupportsTools } from "core/llm/autodetect"; +import { useEffect } from "react"; +import styled from "styled-components"; +import { defaultBorderRadius, lightGray, vscInputBackground } from ".."; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { selectDefaultModel } from "../../redux/slices/configSlice"; +import { + cycleMode, + selectCurrentMode, + setMode, +} from "../../redux/slices/sessionSlice"; +import { + fontSize, + getFontSize, + getMetaKeyLabel, + isJetBrains, +} from "../../util"; + +const StyledListboxButton = styled(Listbox.Button)` + font-family: inherit; + display: flex; + align-items: center; + gap: 2px; + border: none; + cursor: pointer; + font-size: ${getFontSize() - 2}px; + background: transparent; + color: ${lightGray}; + &:focus { + outline: none; + } +`; + +const StyledListboxOptions = styled(Listbox.Options)` + margin-top: 4px; + position: absolute; + list-style: none; + padding: 0px; + min-width: 180px; + cursor: default; + display: flex; + flex-direction: column; + border-radius: ${defaultBorderRadius}; + border: 0.5px solid ${lightGray}; + background-color: ${vscInputBackground}; +`; + +const StyledListboxOption = styled(Listbox.Option)` + border-radius: ${defaultBorderRadius}; + padding: 6px 12px; + cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; + display: flex; + align-items: center; + gap: 8px; + opacity: ${(props) => (props.disabled ? 0.5 : 1)}; + + &:hover { + background: ${(props) => + props.disabled ? "transparent" : `${lightGray}33`}; + } +`; + +const ShortcutText = styled.span` + color: ${lightGray}; + font-size: ${getFontSize() - 3}px; + margin-right: auto; +`; + +function ModeSelect() { + const dispatch = useAppDispatch(); + const mode = useAppSelector(selectCurrentMode); + const selectedModel = useAppSelector(selectDefaultModel); + const agentModeSupported = selectedModel && modelSupportsTools(selectedModel); + const jetbrains = isJetBrains(); + + const getModeIcon = (mode: MessageModes) => { + switch (mode) { + case "agent": + return ; + case "chat": + return ; + case "edit": + return ; + } + }; + + // Switch to chat mode if agent mode is selected but not supported + useEffect(() => { + if (mode === "agent" && !agentModeSupported) { + dispatch(setMode("chat")); + } + }, [mode, agentModeSupported, dispatch]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "." && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + dispatch(cycleMode({ isJetBrains: jetbrains })); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [dispatch, mode, jetbrains]); + + return ( + { + dispatch(setMode(newMode)); + }} + > +
+ +
+ {getModeIcon(mode)} + {mode.charAt(0).toUpperCase() + mode.slice(1)} +
+
+ + + + Agent + {/* */} + {mode === "agent" && } + {!agentModeSupported && (Not supported)} + + + + + Chat + {getMetaKeyLabel()}L + {mode === "chat" && } + + + {!jetbrains && ( + + + Edit + {getMetaKeyLabel()}I + {mode === "edit" && } + + )} + +
+ {getMetaKeyLabel()} + . for next mode +
+
+
+
+ ); +} + +export default ModeSelect; diff --git a/gui/src/components/modelSelection/ModelSelect.tsx b/gui/src/components/modelSelection/ModelSelect.tsx index 3bda3b95ec..437e2304ff 100644 --- a/gui/src/components/modelSelection/ModelSelect.tsx +++ b/gui/src/components/modelSelection/ModelSelect.tsx @@ -1,11 +1,10 @@ import { Listbox } from "@headlessui/react"; import { + CheckIcon, ChevronDownIcon, Cog6ToothIcon, CubeIcon, PlusIcon, - TrashIcon, - CheckIcon, } from "@heroicons/react/24/outline"; import { useContext, useEffect, useRef, useState } from "react"; import { useDispatch } from "react-redux"; @@ -20,8 +19,7 @@ import { setDefaultModel, } from "../../redux/slices/configSlice"; import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice"; -import { getFontSize, isMetaEquivalentKeyPressed } from "../../util"; -import ConfirmationDialog from "../dialogs/ConfirmationDialog"; +import { fontSize, isMetaEquivalentKeyPressed } from "../../util"; import Shortcut from "../gui/Shortcut"; import { Divider } from "./platform/shared"; @@ -29,7 +27,6 @@ interface ModelOptionProps { option: Option; idx: number; showMissingApiKeyMsg: boolean; - showDelete?: boolean; isSelected?: boolean; } @@ -48,7 +45,7 @@ const StyledListboxButton = styled(Listbox.Button)` gap: 2px; border: none; cursor: pointer; - font-size: ${getFontSize() - 2}px; + font-size: ${fontSize(-3)}; background: transparent; color: ${lightGray}; &:focus { @@ -67,6 +64,7 @@ const StyledListboxOptions = styled(Listbox.Options)<{ $showabove: boolean }>` display: flex; flex-direction: column; + font-size: ${fontSize(-3)}; border-radius: ${defaultBorderRadius}; border: 0.5px solid ${lightGray}; background-color: ${vscInputBackground}; @@ -81,7 +79,7 @@ const StyledListboxOptions = styled(Listbox.Options)<{ $showabove: boolean }>` const StyledListboxOption = styled(Listbox.Option)<{ isDisabled?: boolean }>` border-radius: ${defaultBorderRadius}; - padding: 6px 12px; + padding: 4px 12px; ${({ isDisabled }) => !isDisabled && @@ -115,7 +113,6 @@ const IconBase = styled.div<{ $hovered: boolean }>` } `; -const StyledTrashIcon = styled(IconBase).attrs({ as: TrashIcon })``; const StyledCog6ToothIcon = styled(IconBase).attrs({ as: Cog6ToothIcon })``; function modelSelectTitle(model: any): string { @@ -132,35 +129,13 @@ function modelSelectTitle(model: any): string { function ModelOption({ option, idx, - showDelete, showMissingApiKeyMsg, isSelected, }: ModelOptionProps) { const ideMessenger = useContext(IdeMessengerContext); - const dispatch = useDispatch(); const [hovered, setHovered] = useState(false); - function onClickDelete(e: any) { - e.stopPropagation(); - e.preventDefault(); - - dispatch(setShowDialog(true)); - dispatch( - setDialogMessage( - { - ideMessenger.post("config/deleteModel", { - title: option.title, - }); - }} - />, - ), - ); - } - function onClickGear(e: any) { e.stopPropagation(); e.preventDefault(); @@ -187,7 +162,7 @@ function ModelOption({
- + {option.title} {showMissingApiKeyMsg && ( @@ -199,12 +174,7 @@ function ModelOption({
- {showDelete && ( - - )} - {isSelected && ( - - )} + {isSelected && }
@@ -216,7 +186,6 @@ function ModelSelect() { const dispatch = useDispatch(); const defaultModel = useAppSelector(selectDefaultModel); const allModels = useAppSelector((state) => state.config.config.models); - const ideMessenger = useContext(IdeMessengerContext); const [showAbove, setShowAbove] = useState(false); const buttonRef = useRef(null); const [options, setOptions] = useState([]); @@ -342,7 +311,6 @@ function ModelSelect() { option={option} idx={idx} key={idx} - showDelete={options.length > 1} showMissingApiKeyMsg={option.apiKey === ""} isSelected={option.value === defaultModel?.title} /> @@ -360,7 +328,7 @@ function ModelSelect() { value={"addModel" as any} >
- + Add Chat model
@@ -369,7 +337,7 @@ function ModelSelect() { - + meta ' to toggle model
diff --git a/gui/src/components/modelSelection/platform/AssistantSelect.tsx b/gui/src/components/modelSelection/platform/AssistantSelect.tsx index 3bba921f80..43caa02a93 100644 --- a/gui/src/components/modelSelection/platform/AssistantSelect.tsx +++ b/gui/src/components/modelSelection/platform/AssistantSelect.tsx @@ -6,19 +6,24 @@ import { useAuth } from "../../../context/Auth"; import { IdeMessengerContext } from "../../../context/IdeMessenger"; import { cycleProfile } from "../../../redux"; import { useAppDispatch } from "../../../redux/hooks"; -import { getFontSize, isMetaEquivalentKeyPressed } from "../../../util"; -import PopoverTransition from "../../mainInput/InputToolbar/PopoverTransition"; +import { fontSize, isMetaEquivalentKeyPressed } from "../../../util"; +import PopoverTransition from "../../mainInput/InputToolbar/bottom/PopoverTransition"; import AssistantIcon from "./AssistantIcon"; import { AssistantSelectOptions } from "./AssistantSelectOptions"; function AssistantSelectButton(props: { selectedProfile: ProfileDescription }) { return (
-
- +
+
- {props.selectedProfile.title} -
); } @@ -63,10 +68,10 @@ export default function AssistantSelect() { orgSlug: selectedOrganization?.slug, }); }} - className="text-lightgray hover:bg mb-1 mr-3 flex cursor-pointer items-center gap-1 whitespace-nowrap" - style={{ fontSize: `${getFontSize() - 2}px` }} + className="hover:bg flex cursor-pointer select-none items-center gap-1 whitespace-nowrap text-gray-400" + style={{ fontSize: fontSize(-3) }} > - Create your first assistant + Create your first assistant
); } @@ -77,8 +82,8 @@ export default function AssistantSelect() { diff --git a/gui/src/editorInset/EditorInset.tsx b/gui/src/editorInset/EditorInset.tsx deleted file mode 100644 index 3e90926b12..0000000000 --- a/gui/src/editorInset/EditorInset.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useRef } from "react"; -import styled from "styled-components"; -import { defaultBorderRadius } from "../components"; -import TipTapEditor from "../components/mainInput/TipTapEditor"; -import useSetup from "../hooks/useSetup"; -import { selectSlashCommandComboBoxInputs } from "../redux/selectors"; -import { useAppSelector } from "../redux/hooks"; - -const EditorInsetDiv = styled.div` - max-width: 500px; - position: relative; - display: flex; - border-radius: ${defaultBorderRadius}; - // box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.4); -`; - -function EditorInset() { - const availableSlashCommands = useAppSelector( - selectSlashCommandComboBoxInputs, - ); - const availableContextProviders = useAppSelector( - (store) => store.config.config.contextProviders, - ); - - useSetup(); - - const elementRef = useRef(null); - - return ( - - { - console.log("Enter: ", e, modifiers); - }} - historyKey="chat" - inputId="editor-inset" - /> - - ); -} - -export default EditorInset; diff --git a/gui/src/editorInset/main.tsx b/gui/src/editorInset/main.tsx deleted file mode 100644 index d15005ee79..0000000000 --- a/gui/src/editorInset/main.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { Provider } from "react-redux"; -import "../index.css"; -import { store } from "../redux/store"; - -import CustomPostHogProvider from "../hooks/CustomPostHogProvider"; -import EditorInset from "./EditorInset"; - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - - , -); diff --git a/gui/src/pages/More/MCPServersPreview.tsx b/gui/src/pages/More/MCPServersPreview.tsx deleted file mode 100644 index 3d12a8f810..0000000000 --- a/gui/src/pages/More/MCPServersPreview.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { - ArrowPathIcon, - CircleStackIcon, - CommandLineIcon, - InformationCircleIcon, - WrenchScrewdriverIcon, -} from "@heroicons/react/24/outline"; -import { MCPServerStatus } from "core"; -import { useContext } from "react"; -import { SecondaryButton } from "../../components"; -import { ToolTip } from "../../components/gui/Tooltip"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { useAppDispatch, useAppSelector } from "../../redux/hooks"; -import { updateConfig } from "../../redux/slices/configSlice"; - -interface MCPServerStatusProps { - server: MCPServerStatus; -} -function MCPServerPreview({ server }: MCPServerStatusProps) { - const ideMessenger = useContext(IdeMessengerContext); - const config = useAppSelector((store) => store.config.config); - const dispatch = useAppDispatch(); - const toolsTooltipId = `${server.id}-tools`; - const promptsTooltipId = `${server.id}-prompts`; - const resourcesTooltipId = `${server.id}-resources`; - const errorsTooltipId = `${server.id}-errors`; - - async function onRefresh() { - // optimistic config update - dispatch( - updateConfig({ - ...config, - mcpServerStatuses: config.mcpServerStatuses.map((s) => - s.id === server.id - ? { - ...s, - status: "connecting", - } - : s, - ), - }), - ); - ideMessenger.post("mcp/reloadServer", { - id: server.id, - }); - } - - return ( -
-

{server.name}

-
-
- {server.status === "not-connected" ? ( -
-
- Not connected -
- ) : server.status === "connected" ? ( -
-
- Connected -
- ) : server.status === "connecting" ? ( -
-
- Connecting... -
- ) : ( -
-
- Error -
- )} - {server.errors.length ? ( - <> - - - {server.errors.map((error, idx) => ( - {error} - ))} - {server.errors.length === 0 ? ( - No known errors - ) : null} - - - ) : null} -
-
-
- -
- Refresh -
-
-
- {/* Tools */} -
- - {`Tools: ${server.tools.length}`} -
- - {server.tools.map((tool, idx) => ( - {tool.name} - ))} - {server.tools.length === 0 ? ( - No tools - ) : null} - - - {/* Prompts */} -
- - {`Prompts: ${server.prompts.length}`} -
- - {server.prompts.map((prompt, idx) => ( - {prompt.name} - ))} - {server.prompts.length === 0 ? ( - No prompts - ) : null} - - {/* Resources */} -
- - {`Resources: ${server.resources.length}`} -
- - {server.resources.map((resource, idx) => ( - {resource.name} - ))} - {server.resources.length === 0 ? ( - No resources - ) : null} - -
-
- ); -} -function MCPServersPreview() { - const servers = useAppSelector( - (store) => store.config.config.mcpServerStatuses, - ); - const ideMessenger = useContext(IdeMessengerContext); - - return ( -
-
-

MCP Servers

-
- -
-
- {servers.length === 0 && ( - { - ideMessenger.post("config/openProfile", { - profileId: undefined, - }); - }} - > - Add MCP Servers - - )} -
- {servers.map((server, idx) => ( - - ))} -
-
- ); -} - -export default MCPServersPreview; diff --git a/gui/src/pages/More/More.tsx b/gui/src/pages/More/More.tsx deleted file mode 100644 index a528559bcd..0000000000 --- a/gui/src/pages/More/More.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { - ArrowTopRightOnSquareIcon, - DocumentArrowUpIcon, - TableCellsIcon, -} from "@heroicons/react/24/outline"; -import { useContext } from "react"; -import { useNavigate } from "react-router-dom"; -import DocsIndexingStatuses from "../../components/indexing/DocsIndexingStatuses"; -import PageHeader from "../../components/PageHeader"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { useNavigationListener } from "../../hooks/useNavigationListener"; -import { useAppDispatch, useAppSelector } from "../../redux/hooks"; -import { setOnboardingCard } from "../../redux/slices/uiSlice"; -import { saveCurrentSession } from "../../redux/thunks/session"; -import { AccountButton } from "../config/AccountButton"; -import IndexingProgress from "./IndexingProgress"; -import KeyboardShortcuts from "./KeyboardShortcuts"; -import MCPServersPreview from "./MCPServersPreview"; -import MoreHelpRow from "./MoreHelpRow"; -import { RulesPreview } from "./RulesPreview"; - -function MorePage() { - useNavigationListener(); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); - const ideMessenger = useContext(IdeMessengerContext); - const config = useAppSelector((store) => store.config.config); - const { disableIndexing } = config; - - return ( -
- navigate("/")} - title="Chat" - rightContent={} - /> - -
-
-
-

@codebase index

- - Local embeddings of your codebase - -
- {disableIndexing ? ( -
-

- Indexing is disabled -

-

- Open settings and toggle Disable Indexing to - re-enable -

-
- ) : ( - - )} -
- -
- -
- -
- -
- -
- -
- -
-

Help center

-
- - ideMessenger.post("openUrl", "https://docs.continue.dev/") - } - /> - - - ideMessenger.post( - "openUrl", - "https://github.com/continuedev/continue/issues/new/choose", - ) - } - /> - - - ideMessenger.post("openUrl", "https://discord.gg/vapESyrFmJ") - } - /> - - navigate("/stats")} - /> - - { - navigate("/"); - // Used to clear the chat panel before showing onboarding card - await dispatch( - saveCurrentSession({ - openNewSession: true, - generateTitle: true, - }), - ); - dispatch(setOnboardingCard({ show: true, activeTab: "Best" })); - ideMessenger.post("showTutorial", undefined); - }} - /> -
-
- -
-

Keyboard shortcuts

- -
-
-
- ); -} - -export default MorePage; diff --git a/gui/src/pages/More/index.ts b/gui/src/pages/More/index.ts deleted file mode 100644 index 70931b8e65..0000000000 --- a/gui/src/pages/More/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./More"; diff --git a/gui/src/pages/config/AccountButton.tsx b/gui/src/pages/config/AccountButton.tsx index 3fb8427d24..0eaf4e4719 100644 --- a/gui/src/pages/config/AccountButton.tsx +++ b/gui/src/pages/config/AccountButton.tsx @@ -4,9 +4,10 @@ import { Fragment } from "react"; import { SecondaryButton } from "../../components"; import { useAuth } from "../../context/Auth"; +import { ScopeSelect } from "./ScopeSelect"; export function AccountButton() { - const { session, logout, login } = useAuth(); + const { session, logout, login, organizations } = useAuth(); if (!session) { return ( @@ -16,7 +17,7 @@ export function AccountButton() { return ( - + @@ -37,6 +38,14 @@ export function AccountButton() { {session.account.id}
+ {organizations.length > 0 && ( +
+ + +
+ )} Sign out diff --git a/gui/src/pages/config/AccountManagement.tsx b/gui/src/pages/config/AccountManagement.tsx deleted file mode 100644 index 029718ed8a..0000000000 --- a/gui/src/pages/config/AccountManagement.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { Listbox, Transition } from "@headlessui/react"; -import { - ArrowTopRightOnSquareIcon, - ChevronUpDownIcon, - PlusCircleIcon, -} from "@heroicons/react/24/outline"; -import { Fragment, useContext } from "react"; -import AssistantIcon from "../../components/modelSelection/platform/AssistantIcon"; -import { useAuth } from "../../context/Auth"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { selectProfileThunk } from "../../redux"; -import { useAppDispatch } from "../../redux/hooks"; -import { ScopeSelect } from "./ScopeSelect"; - -export function AccountManagement({ hubEnabled }: { hubEnabled: boolean }) { - const dispatch = useAppDispatch(); - const ideMessenger = useContext(IdeMessengerContext); - - const { session, login, profiles, selectedProfile, selectedOrganization } = - useAuth(); - - const changeProfileId = (id: string) => { - dispatch(selectProfileThunk(id)); - }; - - function handleOpenConfig() { - if (!selectedProfile) { - return; - } - ideMessenger.post("config/openProfile", { - profileId: selectedProfile.id, - }); - } - - return ( -
-
-

Configuration

- {hubEnabled ? ( - // Hub: show org selector - session && ( -
- {`Organization`} - -
- ) - ) : ( - // Continue for teams: show org text -
You are using Continue for Teams
- )} - - {profiles ? ( - <> -
-
- {`${hubEnabled ? "Assistant" : "Profile"}`} -
- - {({ open }) => ( -
- -
- {selectedProfile && ( - - )} - - {selectedProfile?.title ?? "No Assistant Selected"} - -
-
-
-
- - - - {profiles.map((option, idx) => ( - - - - {option.title} - - - ))} - {hubEnabled && ( - { - if (session) { - ideMessenger.post("controlPlane/openUrl", { - path: "new", - orgSlug: selectedOrganization?.slug, - }); - } else { - login(false); - } - }} - > - - - Create new Assistant - - - )} - - - - {selectedProfile && ( -
- - - {hubEnabled - ? "Open Assistant configuration" - : "View Workspace"} - -
- )} -
- )} -
-
- - ) : ( -
Loading...
- )} -
-
- ); -} diff --git a/gui/src/pages/config/HelpCenterSection.tsx b/gui/src/pages/config/HelpCenterSection.tsx new file mode 100644 index 0000000000..6e9433d59d --- /dev/null +++ b/gui/src/pages/config/HelpCenterSection.tsx @@ -0,0 +1,80 @@ +import { + ArrowTopRightOnSquareIcon, + DocumentArrowUpIcon, + TableCellsIcon, +} from "@heroicons/react/24/outline"; +import { useContext } from "react"; +import { useNavigate } from "react-router-dom"; +import { IdeMessengerContext } from "../../context/IdeMessenger"; +import { useAppDispatch } from "../../redux/hooks"; +import { setOnboardingCard } from "../../redux/slices/uiSlice"; +import { saveCurrentSession } from "../../redux/thunks/session"; +import MoreHelpRow from "./MoreHelpRow"; + +export function HelpCenterSection() { + const ideMessenger = useContext(IdeMessengerContext); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + return ( +
+

Help center

+
+ + ideMessenger.post("openUrl", "https://docs.continue.dev/") + } + /> + + + ideMessenger.post( + "openUrl", + "https://github.com/continuedev/continue/issues/new/choose", + ) + } + /> + + + ideMessenger.post("openUrl", "https://discord.gg/vapESyrFmJ") + } + /> + + navigate("/stats")} + /> + + { + navigate("/"); + // Used to clear the chat panel before showing onboarding card + await dispatch( + saveCurrentSession({ + openNewSession: true, + generateTitle: true, + }), + ); + dispatch(setOnboardingCard({ show: true, activeTab: "Best" })); + ideMessenger.post("showTutorial", undefined); + }} + /> +
+
+ ); +} diff --git a/gui/src/pages/More/IndexingProgress/IndexingProgress.tsx b/gui/src/pages/config/IndexingProgress/IndexingProgress.tsx similarity index 100% rename from gui/src/pages/More/IndexingProgress/IndexingProgress.tsx rename to gui/src/pages/config/IndexingProgress/IndexingProgress.tsx diff --git a/gui/src/pages/More/IndexingProgress/IndexingProgressBar.tsx b/gui/src/pages/config/IndexingProgress/IndexingProgressBar.tsx similarity index 100% rename from gui/src/pages/More/IndexingProgress/IndexingProgressBar.tsx rename to gui/src/pages/config/IndexingProgress/IndexingProgressBar.tsx diff --git a/gui/src/pages/More/IndexingProgress/IndexingProgressErrorText.tsx b/gui/src/pages/config/IndexingProgress/IndexingProgressErrorText.tsx similarity index 100% rename from gui/src/pages/More/IndexingProgress/IndexingProgressErrorText.tsx rename to gui/src/pages/config/IndexingProgress/IndexingProgressErrorText.tsx diff --git a/gui/src/pages/More/IndexingProgress/IndexingProgressIndicator.tsx b/gui/src/pages/config/IndexingProgress/IndexingProgressIndicator.tsx similarity index 100% rename from gui/src/pages/More/IndexingProgress/IndexingProgressIndicator.tsx rename to gui/src/pages/config/IndexingProgress/IndexingProgressIndicator.tsx diff --git a/gui/src/pages/More/IndexingProgress/IndexingProgressSubtext.tsx b/gui/src/pages/config/IndexingProgress/IndexingProgressSubtext.tsx similarity index 100% rename from gui/src/pages/More/IndexingProgress/IndexingProgressSubtext.tsx rename to gui/src/pages/config/IndexingProgress/IndexingProgressSubtext.tsx diff --git a/gui/src/pages/More/IndexingProgress/IndexingProgressTitleText.tsx b/gui/src/pages/config/IndexingProgress/IndexingProgressTitleText.tsx similarity index 100% rename from gui/src/pages/More/IndexingProgress/IndexingProgressTitleText.tsx rename to gui/src/pages/config/IndexingProgress/IndexingProgressTitleText.tsx diff --git a/gui/src/pages/More/IndexingProgress/index.ts b/gui/src/pages/config/IndexingProgress/index.ts similarity index 100% rename from gui/src/pages/More/IndexingProgress/index.ts rename to gui/src/pages/config/IndexingProgress/index.ts diff --git a/gui/src/pages/config/IndexingSettingsSection.tsx b/gui/src/pages/config/IndexingSettingsSection.tsx new file mode 100644 index 0000000000..fcdf300d10 --- /dev/null +++ b/gui/src/pages/config/IndexingSettingsSection.tsx @@ -0,0 +1,26 @@ +import { useAppSelector } from "../../redux/hooks"; +import IndexingProgress from "./IndexingProgress"; + +export function IndexingSettingsSection() { + const config = useAppSelector((state) => state.config.config); + return ( +
+
+

@codebase index

+ + Local embeddings of your codebase + +
+ {config.disableIndexing ? ( +
+

Indexing is disabled

+

+ Open settings and toggle Disable Indexing to re-enable +

+
+ ) : ( + + )} +
+ ); +} diff --git a/gui/src/pages/More/KeyboardShortcuts.tsx b/gui/src/pages/config/KeyboardShortcuts.tsx similarity index 88% rename from gui/src/pages/More/KeyboardShortcuts.tsx rename to gui/src/pages/config/KeyboardShortcuts.tsx index 7c8e3a1955..072223453d 100644 --- a/gui/src/pages/More/KeyboardShortcuts.tsx +++ b/gui/src/pages/config/KeyboardShortcuts.tsx @@ -4,8 +4,8 @@ import { lightGray, vscForeground, } from "../../components"; -import { getPlatform, isJetBrains } from "../../util"; import { ToolTip } from "../../components/gui/Tooltip"; +import { getPlatform, isJetBrains } from "../../util"; const GridDiv = styled.div` display: grid; @@ -14,6 +14,7 @@ const GridDiv = styled.div` padding: 1rem 0; justify-items: center; align-items: center; + overflow-x: hidden; `; const StyledKeyDiv = styled.div` @@ -64,7 +65,7 @@ function KeyboardShortcut(props: KeyboardShortcutProps) { const shortcut = getPlatform() === "mac" ? props.mac : props.windows; return (
- {props.description} + {props.description}
{shortcut.split(" ").map((key, i) => { return ; @@ -201,20 +202,23 @@ const jetbrainsShortcuts: KeyboardShortcutProps[] = [ function KeyboardShortcuts() { return ( - - {(isJetBrains() ? jetbrainsShortcuts : vscodeShortcuts).map( - (shortcut, i) => { - return ( - - ); - }, - )} - +
+

Keyboard shortcuts

+ + {(isJetBrains() ? jetbrainsShortcuts : vscodeShortcuts).map( + (shortcut, i) => { + return ( + + ); + }, + )} + +
); } diff --git a/gui/src/pages/config/MCPServersPreview.tsx b/gui/src/pages/config/MCPServersPreview.tsx new file mode 100644 index 0000000000..9404107d93 --- /dev/null +++ b/gui/src/pages/config/MCPServersPreview.tsx @@ -0,0 +1,170 @@ +import { + ArrowPathIcon, + CircleStackIcon, + CommandLineIcon, + InformationCircleIcon, + WrenchScrewdriverIcon, +} from "@heroicons/react/24/outline"; +import { MCPServerStatus } from "core"; +import { useContext } from "react"; +import { ToolTip } from "../../components/gui/Tooltip"; +import { AddBlockButton } from "../../components/mainInput/Lump/sections/AddBlockButton"; +import { IdeMessengerContext } from "../../context/IdeMessenger"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { updateConfig } from "../../redux/slices/configSlice"; +import { fontSize } from "../../util"; + +interface MCPServerStatusProps { + server: MCPServerStatus; +} +function MCPServerPreview({ server }: MCPServerStatusProps) { + const ideMessenger = useContext(IdeMessengerContext); + const config = useAppSelector((store) => store.config.config); + const dispatch = useAppDispatch(); + const toolsTooltipId = `${server.id}-tools`; + const promptsTooltipId = `${server.id}-prompts`; + const resourcesTooltipId = `${server.id}-resources`; + const errorsTooltipId = `${server.id}-errors`; + + async function onRefresh() { + // optimistic config update + dispatch( + updateConfig({ + ...config, + mcpServerStatuses: config.mcpServerStatuses.map((s) => + s.id === server.id + ? { + ...s, + status: "connecting", + } + : s, + ), + }), + ); + ideMessenger.post("mcp/reloadServer", { + id: server.id, + }); + } + + return ( +
+
+ {/* Name and Status */} +

{server.name}

+ + {/* Error indicator if any */} + {server.errors.length ? ( + <> + + + {server.errors.map((error, idx) => ( + {error} + ))} + + + ) : null} + + {/* Tools, Prompts, Resources with counts */} +
+
+ + {server.tools.length} + + {server.tools.map((tool, idx) => ( + {tool.name} + ))} + {server.tools.length === 0 && ( + No tools + )} + +
+
+ + {server.prompts.length} + + {server.prompts.map((prompt, idx) => ( + {prompt.name} + ))} + {server.prompts.length === 0 && ( + No prompts + )} + +
+
+ + {server.resources.length} + + {server.resources.map((resource, idx) => ( + {resource.name} + ))} + {server.resources.length === 0 && ( + No resources + )} + +
+
+
+ + {/* Refresh button */} +
+
+ +
+ +
+
+
+ ); +} + +function MCPServersPreview() { + const servers = useAppSelector( + (store) => store.config.config.mcpServerStatuses, + ); + const ideMessenger = useContext(IdeMessengerContext); + + return ( +
+
+ {servers.map((server, idx) => ( + + ))} +
+ +
+ ); +} + +export default MCPServersPreview; diff --git a/gui/src/pages/config/ModelRoleSelector.tsx b/gui/src/pages/config/ModelRoleSelector.tsx index d9d6196a73..bd777c44b7 100644 --- a/gui/src/pages/config/ModelRoleSelector.tsx +++ b/gui/src/pages/config/ModelRoleSelector.tsx @@ -1,15 +1,12 @@ import { Listbox, Transition } from "@headlessui/react"; -import { - ChevronUpDownIcon, - InformationCircleIcon, -} from "@heroicons/react/24/outline"; +import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/outline"; import { type ModelDescription } from "core"; import { Fragment } from "react"; +import { defaultBorderRadius } from "../../components"; import { ToolTip } from "../../components/gui/Tooltip"; +import InfoHover from "../../components/InfoHover"; +import { fontSize } from "../../util"; -/** - * A component for selecting a model for a specific role - */ interface ModelRoleSelectorProps { models: ModelDescription[]; selectedModel: ModelDescription | null; @@ -32,66 +29,81 @@ const ModelRoleSelector = ({ return ( <>
- {displayName} - + + {displayName} + + {description}
- - {models.length === 0 ? ( - - {["Chat", "Apply", "Edit"].includes(displayName) - ? "None (defaulting to Chat model)" - : "None"} - - ) : ( - - {({ open }) => ( -
- - + + {({ open }) => ( +
+ 0 ? "hover:bg-vsc-input-background cursor-pointer" : "cursor-not-allowed opacity-50"} text-vsc-foreground relative m-0 flex w-full items-center justify-between rounded-md border border-solid px-1.5 py-0.5 text-left text-sm`} + > + {models.length === 0 ? ( + {`No ${displayName} models${["Chat", "Apply", "Edit"].includes(displayName) ? ". Using chat model" : ""}`} + ) : ( + {selectedModel?.title ?? `Select ${displayName} model`} + )} + {models.length ? (
-
-
+ ) : null} + - + - - {models.map((option, idx) => ( - ( + + - - {option.title} - - - ))} - - -
- )} -
- )} + {option.title} +
+ {option.title === selectedModel?.title && ( +
+ )} +
); }; diff --git a/gui/src/pages/config/More.tsx b/gui/src/pages/config/More.tsx new file mode 100644 index 0000000000..081c6dd372 --- /dev/null +++ b/gui/src/pages/config/More.tsx @@ -0,0 +1,29 @@ +import { useContext } from "react"; +import { useNavigate } from "react-router-dom"; +import PageHeader from "../../components/PageHeader"; +import { IdeMessengerContext } from "../../context/IdeMessenger"; +import { useNavigationListener } from "../../hooks/useNavigationListener"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { AccountButton } from "./AccountButton"; + +function MorePage() { + useNavigationListener(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const ideMessenger = useContext(IdeMessengerContext); + const config = useAppSelector((store) => store.config.config); + const { disableIndexing } = config; + + return ( +
+ navigate("/")} + title="Chat" + rightContent={} + /> +
+ ); +} + +export default MorePage; diff --git a/gui/src/pages/More/MoreHelpRow.tsx b/gui/src/pages/config/MoreHelpRow.tsx similarity index 100% rename from gui/src/pages/More/MoreHelpRow.tsx rename to gui/src/pages/config/MoreHelpRow.tsx diff --git a/gui/src/pages/config/UserSettingsForm.tsx b/gui/src/pages/config/UserSettingsForm.tsx new file mode 100644 index 0000000000..f2555c4157 --- /dev/null +++ b/gui/src/pages/config/UserSettingsForm.tsx @@ -0,0 +1,298 @@ +import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { + SharedConfigSchema, + modifyAnyConfigWithSharedConfig, +} from "core/config/sharedConfig"; +import { useContext, useEffect, useState } from "react"; +import { Input } from "../../components"; +import NumberInput from "../../components/gui/NumberInput"; +import { Select } from "../../components/gui/Select"; +import ToggleSwitch from "../../components/gui/Switch"; +import { useAuth } from "../../context/Auth"; +import { IdeMessengerContext } from "../../context/IdeMessenger"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { updateConfig } from "../../redux/slices/configSlice"; +import { getFontSize } from "../../util"; + +export function UserSettingsForm() { + /////// User settings section ////// + const dispatch = useAppDispatch(); + const { selectedProfile, controlServerBetaEnabled } = useAuth(); + const ideMessenger = useContext(IdeMessengerContext); + const config = useAppSelector((state) => state.config.config); + + function handleUpdate(sharedConfig: SharedConfigSchema) { + // Optimistic update + const updatedConfig = modifyAnyConfigWithSharedConfig(config, sharedConfig); + dispatch(updateConfig(updatedConfig)); + // IMPORTANT no need for model role updates (separate logic for selected model roles) + // simply because this function won't be used to update model roles + + // Actual update to core which propagates back with config update event + ideMessenger.post("config/updateSharedConfig", sharedConfig); + } + + // Disable autocomplete + const disableAutocompleteInFiles = ( + config.tabAutocompleteOptions?.disableInFiles ?? [] + ).join(", "); + const [formDisableAutocomplete, setFormDisableAutocomplete] = useState( + disableAutocompleteInFiles, + ); + + useEffect(() => { + // Necessary so that reformatted/trimmed values don't cause dirty state + setFormDisableAutocomplete(disableAutocompleteInFiles); + }, [disableAutocompleteInFiles]); + + // Workspace prompts + const promptPath = config.experimental?.promptPath || ""; + const [formPromptPath, setFormPromptPath] = useState(promptPath); + const cancelChangePromptPath = () => { + setFormPromptPath(promptPath); + }; + const handleSubmitPromptPath = () => { + handleUpdate({ + promptPath: formPromptPath || "", + }); + }; + + useEffect(() => { + // Necessary so that reformatted/trimmed values don't cause dirty state + setFormPromptPath(promptPath); + }, [promptPath]); + + // TODO defaults are in multiple places, should be consolidated and probably not explicit here + const enableSessionTabs = config.ui?.showSessionTabs ?? false; + const codeWrap = config.ui?.codeWrap ?? false; + const showChatScrollbar = config.ui?.showChatScrollbar ?? false; + const formatMarkdownOutput = !(config.ui?.displayRawMarkdown ?? false); + const enableSessionTitles = !(config.disableSessionTitles ?? false); + const readResponseTTS = config.experimental?.readResponseTTS ?? false; + + const allowAnonymousTelemetry = config.allowAnonymousTelemetry ?? true; + const enableIndexing = !(config.disableIndexing ?? false); + + const useAutocompleteCache = config.tabAutocompleteOptions?.useCache ?? true; + const useChromiumForDocsCrawling = + config.experimental?.useChromiumForDocsCrawling ?? false; + const codeBlockToolbarPosition = config.ui?.codeBlockToolbarPosition ?? "top"; + const useAutocompleteMultilineCompletions = + config.tabAutocompleteOptions?.multilineCompletions ?? "auto"; + const fontSize = getFontSize(); + + const cancelChangeDisableAutocomplete = () => { + setFormDisableAutocomplete(disableAutocompleteInFiles); + }; + const handleDisableAutocompleteSubmit = () => { + handleUpdate({ + disableAutocompleteInFiles: formDisableAutocomplete + .split(",") + .map((val) => val.trim()) + .filter((val) => !!val), + }); + }; + + const [hubEnabled, setHubEnabled] = useState(false); + useEffect(() => { + ideMessenger.ide.getIdeSettings().then(({ continueTestEnvironment }) => { + setHubEnabled(continueTestEnvironment === "production"); + }); + }, [ideMessenger]); + + return ( +
+ {!controlServerBetaEnabled || hubEnabled ? ( +
+
+
+
+

User settings

+
+ +
+ + handleUpdate({ + showSessionTabs: !enableSessionTabs, + }) + } + text="Show Session Tabs" + /> + + handleUpdate({ + codeWrap: !codeWrap, + }) + } + text="Wrap Codeblocks" + /> + + + handleUpdate({ + showChatScrollbar: !showChatScrollbar, + }) + } + text="Show Chat Scrollbar" + /> + + handleUpdate({ + readResponseTTS: !readResponseTTS, + }) + } + text="Text to Speech Output" + /> + + {/* + handleUpdate({ + useChromiumForDocsCrawling: !useChromiumForDocsCrawling, + }) + } + text="Use Chromium for Docs Crawling" + /> */} + + handleUpdate({ + disableSessionTitles: !enableSessionTitles, + }) + } + text="Enable Session Titles" + /> + + handleUpdate({ + displayRawMarkdown: !formatMarkdownOutput, + }) + } + text="Format Markdown" + /> + + + handleUpdate({ + allowAnonymousTelemetry: !allowAnonymousTelemetry, + }) + } + text="Allow Anonymous Telemetry" + /> + + + handleUpdate({ + disableIndexing: !enableIndexing, + }) + } + text="Enable Indexing" + /> + + {/* + handleUpdate({ + useAutocompleteCache: !useAutocompleteCache, + }) + } + text="Use Autocomplete Cache" + /> */} + + + + + +
{ + e.preventDefault(); + handleDisableAutocompleteSubmit(); + }} + > +
+ Disable autocomplete in files +
+ { + setFormDisableAutocomplete(e.target.value); + }} + /> +
+ {formDisableAutocomplete !== + disableAutocompleteInFiles ? ( + <> +
+ +
+
+ +
+ + ) : ( +
+ +
+ )} +
+
+
+ + Comma-separated list of path matchers + +
+
+
+
+
+ ) : null} +
+ ); +} diff --git a/gui/src/pages/config/index.tsx b/gui/src/pages/config/index.tsx index d4fcb40378..a336cb69c0 100644 --- a/gui/src/pages/config/index.tsx +++ b/gui/src/pages/config/index.tsx @@ -1,401 +1,92 @@ -import { ModelRole } from "@continuedev/config-yaml"; -import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { ModelDescription } from "core"; import { - SharedConfigSchema, - modifyAnyConfigWithSharedConfig, -} from "core/config/sharedConfig"; -import { useContext, useEffect, useState } from "react"; + AcademicCapIcon, + CircleStackIcon, + Cog6ToothIcon, + QuestionMarkCircleIcon, +} from "@heroicons/react/24/outline"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Input } from "../../components"; -import NumberInput from "../../components/gui/NumberInput"; -import { Select } from "../../components/gui/Select"; -import ToggleSwitch from "../../components/gui/Switch"; import PageHeader from "../../components/PageHeader"; -import { useAuth } from "../../context/Auth"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; import { useNavigationListener } from "../../hooks/useNavigationListener"; -import { useAppDispatch, useAppSelector } from "../../redux/hooks"; -import { - selectDefaultModel, - setDefaultModel, - updateConfig, -} from "../../redux/slices/configSlice"; -import { getFontSize, isJetBrains } from "../../util"; +import { fontSize } from "../../util"; import { AccountButton } from "./AccountButton"; -import { AccountManagement } from "./AccountManagement"; -import ModelRoleSelector from "./ModelRoleSelector"; +import { HelpCenterSection } from "./HelpCenterSection"; +import { IndexingSettingsSection } from "./IndexingSettingsSection"; +import KeyboardShortcuts from "./KeyboardShortcuts"; +import { UserSettingsForm } from "./UserSettingsForm"; + +type TabOption = { + id: string; + label: string; + component: React.ReactNode; + icon: React.ReactNode; +}; function ConfigPage() { useNavigationListener(); - const dispatch = useAppDispatch(); const navigate = useNavigate(); - const ideMessenger = useContext(IdeMessengerContext); - - const { selectedProfile, controlServerBetaEnabled } = useAuth(); - - const [hubEnabled, setHubEnabled] = useState(false); - useEffect(() => { - ideMessenger.ide.getIdeSettings().then(({ continueTestEnvironment }) => { - setHubEnabled(continueTestEnvironment === "production"); - }); - }, [ideMessenger]); - - // NOTE Hub takes priority over Continue for Teams - // Since teams will be moving to hub, not vice versa - - /////// User settings section ////// - const config = useAppSelector((state) => state.config.config); - const selectedChatModel = useAppSelector(selectDefaultModel); - - function handleUpdate(sharedConfig: SharedConfigSchema) { - // Optimistic update - const updatedConfig = modifyAnyConfigWithSharedConfig(config, sharedConfig); - dispatch(updateConfig(updatedConfig)); - // IMPORTANT no need for model role updates (separate logic for selected model roles) - // simply because this function won't be used to update model roles - - // Actual update to core which propagates back with config update event - ideMessenger.post("config/updateSharedConfig", sharedConfig); - } - - function handleRoleUpdate(role: ModelRole, model: ModelDescription | null) { - if (!selectedProfile) { - return; - } - // Optimistic update - dispatch( - updateConfig({ - ...config, - selectedModelByRole: { - ...config.selectedModelByRole, - [role]: model, - }, - }), - ); - ideMessenger.post("config/updateSelectedModel", { - profileId: selectedProfile.id, - role, - title: model?.title ?? null, - }); - } - - // TODO use handleRoleUpdate for chat - function handleChatModelSelection(model: ModelDescription | null) { - if (!model) { - return; - } - dispatch(setDefaultModel({ title: model.title })); - } - - // TODO defaults are in multiple places, should be consolidated and probably not explicit here - const enableSessionTabs = config.ui?.showSessionTabs ?? false; - const codeWrap = config.ui?.codeWrap ?? false; - const showChatScrollbar = config.ui?.showChatScrollbar ?? false; - const formatMarkdownOutput = !(config.ui?.displayRawMarkdown ?? false); - const enableSessionTitles = !(config.disableSessionTitles ?? false); - const readResponseTTS = config.experimental?.readResponseTTS ?? false; - - const allowAnonymousTelemetry = config.allowAnonymousTelemetry ?? true; - const enableIndexing = !(config.disableIndexing ?? false); - - const useAutocompleteCache = config.tabAutocompleteOptions?.useCache ?? true; - const useChromiumForDocsCrawling = - config.experimental?.useChromiumForDocsCrawling ?? false; - const codeBlockToolbarPosition = config.ui?.codeBlockToolbarPosition ?? "top"; - const useAutocompleteMultilineCompletions = - config.tabAutocompleteOptions?.multilineCompletions ?? "auto"; - const fontSize = getFontSize(); - - // Disable autocomplete - const disableAutocompleteInFiles = ( - config.tabAutocompleteOptions?.disableInFiles ?? [] - ).join(", "); - const [formDisableAutocomplete, setFormDisableAutocomplete] = useState( - disableAutocompleteInFiles, - ); - const cancelChangeDisableAutocomplete = () => { - setFormDisableAutocomplete(disableAutocompleteInFiles); - }; - const handleDisableAutocompleteSubmit = () => { - handleUpdate({ - disableAutocompleteInFiles: formDisableAutocomplete - .split(",") - .map((val) => val.trim()) - .filter((val) => !!val), - }); - }; - - useEffect(() => { - // Necessary so that reformatted/trimmed values don't cause dirty state - setFormDisableAutocomplete(disableAutocompleteInFiles); - }, [disableAutocompleteInFiles]); - - const jetbrains = isJetBrains(); + const [activeTab, setActiveTab] = useState("settings"); + + const tabs: TabOption[] = [ + { + id: "settings", + label: "Settings", + component: , + icon: , + }, + { + id: "indexing", + label: "Indexing", + component: , + icon: , + }, + { + id: "help", + label: "Help", + component: , + icon: , + }, + { + id: "shortcuts", + label: "Shortcuts", + component: , + icon: , + }, + ]; return ( -
- navigate("/")} - title="Chat" - rightContent={} - /> - -
- - - {/* Model Roles as a separate section */} -
-
-

Model Roles

-
- handleChatModelSelection(model)} - /> - handleRoleUpdate("autocomplete", model)} - /> - {/* Jetbrains has a model selector inline */} - {!jetbrains && ( - handleRoleUpdate("edit", model)} - /> - )} - handleRoleUpdate("apply", model)} - /> - handleRoleUpdate("embed", model)} - /> - handleRoleUpdate("rerank", model)} - /> +
+
+ navigate("/")} + title="Chat" + rightContent={} + /> + + {/* Tab Headers */} +
+ {tabs.map((tab) => ( +
setActiveTab(tab.id)} + > + {tab.icon} + {tab.label}
-
+ ))}
+
- {!controlServerBetaEnabled || hubEnabled ? ( -
-
-
-
-

User settings

-
- -
- - handleUpdate({ - showSessionTabs: !enableSessionTabs, - }) - } - text="Show Session Tabs" - /> - - handleUpdate({ - codeWrap: !codeWrap, - }) - } - text="Wrap Codeblocks" - /> - - - handleUpdate({ - showChatScrollbar: !showChatScrollbar, - }) - } - text="Show Chat Scrollbar" - /> - - handleUpdate({ - readResponseTTS: !readResponseTTS, - }) - } - text="Text to Speech Output" - /> - - {/* - handleUpdate({ - useChromiumForDocsCrawling: !useChromiumForDocsCrawling, - }) - } - text="Use Chromium for Docs Crawling" - /> */} - - handleUpdate({ - disableSessionTitles: !enableSessionTitles, - }) - } - text="Enable Session Titles" - /> - - handleUpdate({ - displayRawMarkdown: !formatMarkdownOutput, - }) - } - text="Format Markdown" - /> - - - handleUpdate({ - allowAnonymousTelemetry: !allowAnonymousTelemetry, - }) - } - text="Allow Anonymous Telemetry" - /> - - - handleUpdate({ - disableIndexing: !enableIndexing, - }) - } - text="Enable Indexing" - /> - - {/* - handleUpdate({ - useAutocompleteCache: !useAutocompleteCache, - }) - } - text="Use Autocomplete Cache" - /> */} - - - - - -
{ - e.preventDefault(); - handleDisableAutocompleteSubmit(); - }} - > -
- Disable autocomplete in files -
- { - setFormDisableAutocomplete(e.target.value); - }} - /> -
- {formDisableAutocomplete !== - disableAutocompleteInFiles ? ( - <> -
- -
-
- -
- - ) : ( -
- -
- )} -
-
-
- - Comma-separated list of path matchers - -
-
-
-
-
- ) : null} + {/* Tab Content */} +
+ {tabs.find((tab) => tab.id === activeTab)?.component}
); diff --git a/gui/src/pages/gui/Chat.tsx b/gui/src/pages/gui/Chat.tsx index cd2f72d241..c752568130 100644 --- a/gui/src/pages/gui/Chat.tsx +++ b/gui/src/pages/gui/Chat.tsx @@ -13,25 +13,18 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useSelector } from "react-redux"; import styled from "styled-components"; -import { - Button, - defaultBorderRadius, - lightGray, - vscBackground, -} from "../../components"; +import { Button, lightGray, vscBackground } from "../../components"; import CodeToEditCard from "../../components/CodeToEditCard"; import FeedbackDialog from "../../components/dialogs/FeedbackDialog"; import FreeTrialOverDialog from "../../components/dialogs/FreeTrialOverDialog"; import { useFindWidget } from "../../components/find/FindWidget"; import TimelineItem from "../../components/gui/TimelineItem"; import ChatIndexingPeeks from "../../components/indexing/ChatIndexingPeeks"; +import { NewSessionButton } from "../../components/mainInput/belowMainInput/NewSessionButton"; +import ThinkingBlockPeek from "../../components/mainInput/belowMainInput/ThinkingBlockPeek"; import ContinueInputBox from "../../components/mainInput/ContinueInputBox"; -import { NewSessionButton } from "../../components/mainInput/NewSessionButton"; -import resolveEditorContent from "../../components/mainInput/resolveInput"; -import ThinkingBlockPeek from "../../components/mainInput/ThinkingBlockPeek"; -import AssistantSelect from "../../components/modelSelection/platform/AssistantSelect"; +import resolveEditorContent from "../../components/mainInput/tiptap/resolveInput"; import { useOnboardingCard } from "../../components/OnboardingCard"; -import PageHeader from "../../components/PageHeader"; import StepContainer from "../../components/StepContainer"; import AcceptRejectAllButtons from "../../components/StepContainer/AcceptRejectAllButtons"; import { TabBar } from "../../components/TabBar/TabBar"; @@ -43,11 +36,9 @@ import { selectCurrentToolCall } from "../../redux/selectors/selectCurrentToolCa import { selectDefaultModel } from "../../redux/slices/configSlice"; import { submitEdit } from "../../redux/slices/editModeState"; import { - clearLastEmptyResponse, newSession, selectIsInEditMode, selectIsSingleRangeEditOrInsertion, - setInactive, } from "../../redux/slices/sessionSlice"; import { setDialogEntryOn, @@ -59,11 +50,7 @@ import { cancelStream } from "../../redux/thunks/cancelStream"; import { exitEditMode } from "../../redux/thunks/exitEditMode"; import { loadLastSession } from "../../redux/thunks/session"; import { streamResponseThunk } from "../../redux/thunks/streamResponse"; -import { - getFontSize, - getMetaKeyLabel, - isMetaEquivalentKeyPressed, -} from "../../util"; +import { isMetaEquivalentKeyPressed } from "../../util"; import { FREE_TRIAL_LIMIT_REQUESTS, incrementFreeTrialCount, @@ -78,29 +65,6 @@ import { ToolCallButtons } from "./ToolCallDiv/ToolCallButtonsDiv"; import ToolOutput from "./ToolCallDiv/ToolOutput"; import { useAutoScroll } from "./useAutoScroll"; -const StopButton = styled.div` - background-color: ${vscBackground}; - width: fit-content; - margin-right: auto; - margin-left: auto; - font-size: ${getFontSize() - 2}px; - border: 0.5px solid ${lightGray}; - border-radius: ${defaultBorderRadius}; - padding: 4px 8px; - color: ${lightGray}; - cursor: pointer; - box-shadow: - 0 4px 6px rgba(0, 0, 0, 0.1), - 0 1px 3px rgba(0, 0, 0, 0.08); - transition: box-shadow 0.3s ease; - - &:hover { - box-shadow: - 0 6px 8px rgba(0, 0, 0, 0.15), - 0 3px 6px rgba(0, 0, 0, 0.1); - } -`; - const StepsDiv = styled.div` position: relative; background-color: transparent; @@ -143,7 +107,6 @@ export function Chat() { (store) => store.config.config.ui?.showSessionTabs, ); const defaultModel = useAppSelector(selectDefaultModel); - const ttsActive = useAppSelector((state) => state.ui.ttsActive); const isStreaming = useAppSelector((state) => state.session.isStreaming); const [stepsOpen, setStepsOpen] = useState<(boolean | undefined)[]>([]); const mainTextInputRef = useRef(null); @@ -323,23 +286,6 @@ export function Chat() { return ( <> - {showPageHeader && ( - { - await dispatch( - loadLastSession({ saveCurrentSession: false }), - ); - dispatch(exitEditMode()); - } - : undefined - } - rightContent={useHub && } - /> - )} - {widget} {!!showSessionTabs && } @@ -444,30 +390,7 @@ export function Chat() {
))} -
-
- {ttsActive && ( - { - ideMessenger.post("tts/kill", undefined); - }} - > - ■ Stop TTS - - )} - {isStreaming && ( - { - dispatch(setInactive()); - dispatch(clearLastEmptyResponse()); - }} - > - {getMetaKeyLabel()} ⌫ Cancel - - )} -
- +
{toolCallState?.status === "generated" && } {isInEditMode && history.length === 0 && } diff --git a/gui/src/pages/gui/ToolCallDiv/ToolOutput.tsx b/gui/src/pages/gui/ToolCallDiv/ToolOutput.tsx index 46057e90c5..2289be544a 100644 --- a/gui/src/pages/gui/ToolCallDiv/ToolOutput.tsx +++ b/gui/src/pages/gui/ToolCallDiv/ToolOutput.tsx @@ -1,5 +1,5 @@ import { ContextItemWithId } from "core"; -import ContextItemsPeek from "../../../components/mainInput/ContextItemsPeek"; +import ContextItemsPeek from "../../../components/mainInput/belowMainInput/ContextItemsPeek"; interface ToolOutputProps { contextItems: ContextItemWithId[]; diff --git a/gui/src/pages/migration.tsx b/gui/src/pages/migration.tsx deleted file mode 100644 index b09389c59c..0000000000 --- a/gui/src/pages/migration.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import ContinueButton from "../components/mainInput/ContinueButton"; - -function MigrationPage() { - const navigate = useNavigate(); - return ( -
-

- Migration to config.json -

- -

- Continue now uses a .json config file. We hope that this takes the - guesswork out of setting up. -

- -

- Your configuration should have been automatically migrated, but we - recommend double-checking that everything looks correct. -

- -

- For a summary of what changed and examples of config.json, - please see the{" "} - - migration walkthrough - - , and if you have any questions please reach out to us on{" "} - - Discord - - . -

- - - Note: If you are running the server manually and have not updated the - server, this message does not apply. - - - { - navigate("/"); - }} - disabled={false} - /> -
- ); -} - -export default MigrationPage; diff --git a/gui/src/redux/slices/sessionSlice.ts b/gui/src/redux/slices/sessionSlice.ts index 2fde7aedd9..ba6ff25e14 100644 --- a/gui/src/redux/slices/sessionSlice.ts +++ b/gui/src/redux/slices/sessionSlice.ts @@ -634,6 +634,14 @@ export const sessionSlice = createSlice({ setMode: (state, action: PayloadAction) => { state.mode = action.payload; }, + cycleMode: (state, action: PayloadAction<{ isJetBrains: boolean }>) => { + const modes = action.payload.isJetBrains + ? ["chat", "edit", "agent"] + : ["chat", "agent"]; + const currentIndex = modes.indexOf(state.mode); + const nextIndex = (currentIndex + 1) % modes.length; + state.mode = modes[nextIndex] as MessageModes; + }, setNewestCodeblocksForInput: ( state, { @@ -654,6 +662,9 @@ export const sessionSlice = createSlice({ selectIsInEditMode: (state) => { return state.mode === "edit"; }, + selectCurrentMode: (state) => { + return state.mode; + }, selectIsSingleRangeEditOrInsertion: (state) => { if (state.mode !== "edit") { return false; @@ -668,6 +679,9 @@ export const sessionSlice = createSlice({ selectHasCodeToEdit: (state) => { return state.codeToEdit.length > 0; }, + selectUseTools: (state) => { + return state.mode === "agent"; + }, }, extraReducers: (builder) => { addPassthroughCases(builder, [streamResponseThunk]); @@ -739,13 +753,16 @@ export const { updateSessionMetadata, deleteSessionMetadata, setNewestCodeblocksForInput, + cycleMode, } = sessionSlice.actions; export const { selectIsGatheringContext, selectIsInEditMode, + selectCurrentMode, selectIsSingleRangeEditOrInsertion, selectHasCodeToEdit, + selectUseTools, } = sessionSlice.selectors; export default sessionSlice.reducer; diff --git a/gui/src/redux/slices/uiSlice.ts b/gui/src/redux/slices/uiSlice.ts index d464454688..7bb7fb3fde 100644 --- a/gui/src/redux/slices/uiSlice.ts +++ b/gui/src/redux/slices/uiSlice.ts @@ -22,10 +22,10 @@ type UIState = { isExploreDialogOpen: boolean; hasDismissedExploreDialog: boolean; shouldAddFileForEditing: boolean; - useTools: boolean; toolSettings: { [toolName: string]: ToolSetting }; toolGroupSettings: { [toolGroupName: string]: ToolGroupSetting }; ttsActive: boolean; + isBlockSettingsToolbarExpanded: boolean; }; export const DEFAULT_TOOL_SETTING: ToolSetting = "allowedWithPermission"; @@ -43,7 +43,6 @@ export const uiSlice = createSlice({ ), shouldAddFileForEditing: false, ttsActive: false, - useTools: false, toolSettings: { [BuiltInToolNames.ReadFile]: "allowedWithoutPermission", [BuiltInToolNames.CreateNewFile]: "allowedWithPermission", @@ -57,6 +56,7 @@ export const uiSlice = createSlice({ toolGroupSettings: { BUILT_IN_GROUP_NAME: "include", }, + isBlockSettingsToolbarExpanded: true, } as UIState, reducers: { setOnboardingCard: ( @@ -90,9 +90,6 @@ export const uiSlice = createSlice({ state.hasDismissedExploreDialog = action.payload; }, // Tools - toggleUseTools: (state) => { - state.useTools = !state.useTools; - }, addTool: (state, action: PayloadAction) => { state.toolSettings[action.payload.function.name] = "allowedWithPermission"; @@ -127,6 +124,10 @@ export const uiSlice = createSlice({ setTTSActive: (state, { payload }: PayloadAction) => { state.ttsActive = payload; }, + toggleBlockSettingsToolbar: (state) => { + state.isBlockSettingsToolbarExpanded = + !state.isBlockSettingsToolbarExpanded; + }, }, }); @@ -137,11 +138,11 @@ export const { setShowDialog, setIsExploreDialogOpen, setHasDismissedExploreDialog, - toggleUseTools, toggleToolSetting, toggleToolGroupSetting, addTool, setTTSActive, + toggleBlockSettingsToolbar, } = uiSlice.actions; export default uiSlice.reducer; diff --git a/gui/src/redux/store.ts b/gui/src/redux/store.ts index d2addfacd1..6d6c11b2b3 100644 --- a/gui/src/redux/store.ts +++ b/gui/src/redux/store.ts @@ -59,7 +59,7 @@ const saveSubsetFilters = [ // Don't persist any of the edit state for now createFilter("editModeState", []), createFilter("config", ["defaultModelTitle"]), - createFilter("ui", ["toolSettings", "toolGroupSettings", "useTools"]), + createFilter("ui", ["toolSettings", "toolGroupSettings"]), createFilter("indexing", []), createFilter("tabs", ["tabs"]), // Add this new filter for the profiles slice diff --git a/gui/src/redux/thunks/gatherContext.ts b/gui/src/redux/thunks/gatherContext.ts index 1333639782..ebd50e644e 100644 --- a/gui/src/redux/thunks/gatherContext.ts +++ b/gui/src/redux/thunks/gatherContext.ts @@ -7,7 +7,7 @@ import { RangeInFile, } from "core"; import * as URI from "uri-js"; -import resolveEditorContent from "../../components/mainInput/resolveInput"; +import resolveEditorContent from "../../components/mainInput/tiptap/resolveInput"; import { selectDefaultModel } from "../slices/configSlice"; import { ThunkApiType } from "../store"; diff --git a/gui/src/redux/thunks/streamNormalInput.ts b/gui/src/redux/thunks/streamNormalInput.ts index 76c05f4ec5..41d7a9587f 100644 --- a/gui/src/redux/thunks/streamNormalInput.ts +++ b/gui/src/redux/thunks/streamNormalInput.ts @@ -7,6 +7,7 @@ import { selectDefaultModel } from "../slices/configSlice"; import { abortStream, addPromptCompletionPair, + selectUseTools, setToolGenerated, streamUpdate, } from "../slices/sessionSlice"; @@ -32,15 +33,12 @@ export const streamNormalInput = createAsyncThunk< const toolSettings = state.ui.toolSettings; const toolGroupSettings = state.ui.toolGroupSettings; const streamAborter = state.session.streamAborter; - const useTools = state.ui.useTools; + const useTools = selectUseTools(state); if (!defaultModel) { throw new Error("Default model not defined"); } - const includeTools = - useTools && - modelSupportsTools(defaultModel) && - state.session.mode === "chat"; + const includeTools = useTools && modelSupportsTools(defaultModel); // Send request const gen = extra.ideMessenger.llmStreamChat( diff --git a/gui/src/redux/thunks/streamResponse.ts b/gui/src/redux/thunks/streamResponse.ts index 896c2dd361..84e2edbe58 100644 --- a/gui/src/redux/thunks/streamResponse.ts +++ b/gui/src/redux/thunks/streamResponse.ts @@ -70,7 +70,6 @@ export const streamResponseThunk = createAsyncThunk< await dispatch( streamThunkWrapper(async () => { const state = getState(); - const useTools = state.ui.useTools; const defaultModel = selectDefaultModel(state); const slashCommands = state.config.config.slashCommands || []; const inputIndex = index ?? state.session.history.length; // Either given index or concat to end diff --git a/gui/src/redux/thunks/streamResponseAfterToolCall.ts b/gui/src/redux/thunks/streamResponseAfterToolCall.ts index 5e33a5fb0a..99554a6281 100644 --- a/gui/src/redux/thunks/streamResponseAfterToolCall.ts +++ b/gui/src/redux/thunks/streamResponseAfterToolCall.ts @@ -26,7 +26,6 @@ export const streamResponseAfterToolCall = createAsyncThunk< await dispatch( streamThunkWrapper(async () => { const state = getState(); - const useTools = state.ui.useTools; const initialHistory = state.session.history; const defaultModel = selectDefaultModel(state); diff --git a/gui/src/util/index.ts b/gui/src/util/index.ts index 2636a3b675..f2b18105b3 100644 --- a/gui/src/util/index.ts +++ b/gui/src/util/index.ts @@ -52,6 +52,10 @@ export function getFontSize(): number { return getLocalStorage("fontSize") ?? (isJetBrains() ? 15 : 14); } +export function fontSize(n: number): string { + return `${getFontSize() + n}px`; +} + export function isJetBrains() { return getLocalStorage("ide") === "jetbrains"; } diff --git a/gui/src/util/navigation.ts b/gui/src/util/navigation.ts index eed39333d8..8a7693b32f 100644 --- a/gui/src/util/navigation.ts +++ b/gui/src/util/navigation.ts @@ -3,6 +3,5 @@ export const ROUTES = { HOME: "/", CONFIG_ERROR: "/config-error", CONFIG: "/config", - MORE: "/more", // EXAMPLE_ROUTE_WITH_PARAMS: (params: ParamsType) => `/route/${params}`, };