diff options
Diffstat (limited to 'extension/react-app/src/components')
19 files changed, 1355 insertions, 858 deletions
diff --git a/extension/react-app/src/components/CheckDiv.tsx b/extension/react-app/src/components/CheckDiv.tsx index e595d70b..eaea0dc1 100644 --- a/extension/react-app/src/components/CheckDiv.tsx +++ b/extension/react-app/src/components/CheckDiv.tsx @@ -30,6 +30,9 @@ const StyledDiv = styled.div<{ checked: boolean }>` margin: 0.5rem; height: 1.4em; + + overflow: hidden; + text-overflow: ellipsis; `; function CheckDiv(props: CheckDivProps) { diff --git a/extension/react-app/src/components/ComboBox.tsx b/extension/react-app/src/components/ComboBox.tsx index 48df368b..e63499bc 100644 --- a/extension/react-app/src/components/ComboBox.tsx +++ b/extension/react-app/src/components/ComboBox.tsx @@ -3,12 +3,12 @@ import React, { useContext, useEffect, useImperativeHandle, + useLayoutEffect, useState, } from "react"; import { useCombobox } from "downshift"; import styled from "styled-components"; import { - StyledTooltip, buttonColor, defaultBorderRadius, lightGray, @@ -19,53 +19,51 @@ import { import PillButton from "./PillButton"; import HeaderButtonWithText from "./HeaderButtonWithText"; import { - BookmarkIcon, - DocumentPlusIcon, - FolderArrowDownIcon, ArrowLeftIcon, - PlusIcon, ArrowRightIcon, + MagnifyingGlassIcon, + TrashIcon, } from "@heroicons/react/24/outline"; -import { ContextItem } from "../../../schema/FullState"; import { postVscMessage } from "../vscode"; import { GUIClientContext } from "../App"; import { MeiliSearch } from "meilisearch"; -import { - setBottomMessage, - setDialogMessage, - setShowDialog, -} from "../redux/slices/uiStateSlice"; +import { setBottomMessage } from "../redux/slices/uiStateSlice"; import { useDispatch, useSelector } from "react-redux"; import { RootStore } from "../redux/store"; -import SelectContextGroupDialog from "./dialogs/SelectContextGroupDialog"; -import AddContextGroupDialog from "./dialogs/AddContextGroupDialog"; +import ContinueButton from "./ContinueButton"; const SEARCH_INDEX_NAME = "continue_context_items"; // #region styled components -const mainInputFontSize = 13; -const EmptyPillDiv = styled.div` - padding: 4px; - padding-left: 8px; - padding-right: 8px; - border-radius: ${defaultBorderRadius}; - border: 1px dashed ${lightGray}; - color: ${lightGray}; - background-color: ${vscBackground}; - overflow: hidden; +const HiddenHeaderButtonWithText = styled.button` + opacity: 0; + background-color: transparent; + border: none; + outline: none; + color: ${vscForeground}; + cursor: pointer; display: flex; align-items: center; - text-align: center; - cursor: pointer; - font-size: 13px; + justify-content: center; + height: 0; + aspect-ratio: 1; + padding: 0; + margin-left: -8px; + + border-radius: ${defaultBorderRadius}; - &:hover { - background-color: ${lightGray}; - color: ${vscBackground}; + &:focus { + margin-left: 1px; + height: fit-content; + padding: 3px; + opacity: 1; + outline: 1px solid ${lightGray}; } `; +const mainInputFontSize = 13; + const MainTextInput = styled.textarea<{ inQueryForDynamicProvider: boolean }>` resize: none; @@ -79,20 +77,20 @@ const MainTextInput = styled.textarea<{ inQueryForDynamicProvider: boolean }>` background-color: ${secondaryDark}; color: ${vscForeground}; z-index: 1; - border: 1px solid + border: 0.5px solid ${(props) => props.inQueryForDynamicProvider ? buttonColor : "transparent"}; &:focus { - outline: 1px solid + outline: 0.5px solid ${(props) => (props.inQueryForDynamicProvider ? buttonColor : lightGray)}; - border: 1px solid transparent; + border: 0.5px solid transparent; background-color: ${(props) => props.inQueryForDynamicProvider ? `${buttonColor}22` : secondaryDark}; } &::placeholder { - color: ${lightGray}80; + color: ${lightGray}cc; } `; @@ -110,23 +108,6 @@ const DynamicQueryTitleDiv = styled.div` background-color: ${buttonColor}; `; -const StyledPlusIcon = styled(PlusIcon)` - position: absolute; - right: 0px; - top: 0px; - height: fit-content; - padding: 0; - cursor: pointer; - border-radius: ${defaultBorderRadius}; - z-index: 2; - - background-color: ${vscBackground}; - - &:hover { - background-color: ${secondaryDark}; - } -`; - const UlMaxHeight = 300; const Ul = styled.ul<{ hidden: boolean; @@ -137,7 +118,7 @@ const Ul = styled.ul<{ ${(props) => props.showAbove ? `transform: translateY(-${props.ulHeightPixels + 8}px);` - : `transform: translateY(${5 * mainInputFontSize}px);`} + : `transform: translateY(${5 * mainInputFontSize - 2}px);`} position: absolute; background: ${vscBackground}; color: ${vscForeground}; @@ -148,7 +129,7 @@ const Ul = styled.ul<{ padding: 0; ${({ hidden }) => hidden && "display: none;"} border-radius: ${defaultBorderRadius}; - outline: 1px solid ${lightGray}; + outline: 0.5px solid ${lightGray}; z-index: 2; -ms-overflow-style: none; @@ -180,14 +161,17 @@ const Li = styled.li<{ // #endregion +interface ComboBoxItem { + name: string; + description: string; + id?: string; + content?: string; +} interface ComboBoxProps { - items: { name: string; description: string; id?: string; content?: string }[]; onInputValueChange: (inputValue: string) => void; disabled?: boolean; - onEnter: (e: React.KeyboardEvent<HTMLInputElement>) => void; - selectedContextItems: ContextItem[]; + onEnter: (e?: React.KeyboardEvent<HTMLInputElement>) => void; onToggleAddContext: () => void; - addingHighlightedCode: boolean; } const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { @@ -197,14 +181,11 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { const workspacePaths = useSelector( (state: RootStore) => state.config.workspacePaths ); - const savedContextGroups = useSelector( - (state: RootStore) => state.serverState.saved_context_groups - ); const [history, setHistory] = React.useState<string[]>([]); // The position of the current command you are typing now, so the one that will be appended to history once you press enter const [positionInHistory, setPositionInHistory] = React.useState<number>(0); - const [items, setItems] = React.useState(props.items); + const [items, setItems] = React.useState<ComboBoxItem[]>([]); const inputRef = React.useRef<HTMLInputElement>(null); @@ -217,6 +198,27 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { any | undefined >(undefined); + const sessionId = useSelector( + (state: RootStore) => state.serverState.session_info?.session_id + ); + const availableSlashCommands = useSelector( + (state: RootStore) => state.serverState.slash_commands + ).map((cmd) => { + return { + name: `/${cmd.name}`, + description: cmd.description, + }; + }); + const selectedContextItems = useSelector( + (state: RootStore) => state.serverState.selected_context_items + ); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [sessionId, inputRef.current]); + useEffect(() => { if (!currentlyInContextQuery) { setNestedContextProvider(undefined); @@ -237,7 +239,6 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { useEffect(() => { if (!nestedContextProvider) { - console.log("setting items", nestedContextProvider); setItems( contextProviders?.map((provider) => ({ name: provider.display_title, @@ -248,6 +249,8 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { } }, [nestedContextProvider]); + const [prevInputValue, setPrevInputValue] = useState(""); + const onInputValueChangeCallback = useCallback( ({ inputValue, highlightedIndex }: any) => { // Clear the input @@ -257,6 +260,18 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { setCurrentlyInContextQuery(false); return; } + + // Hacky way of stopping bug where first context provider title is injected into input + if ( + prevInputValue === "" && + contextProviders.some((p) => p.display_title === inputValue) + ) { + downshiftProps.setInputValue(""); + setPrevInputValue(""); + return; + } + setPrevInputValue(inputValue); + if ( inQueryForContextProvider && !inputValue.startsWith(`@${inQueryForContextProvider.title}`) @@ -277,9 +292,10 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { if (nestedContextProvider && !inputValue.endsWith("@")) { // Search only within this specific context provider + const spaceSegs = providerAndQuery.split(" "); getFilteredContextItemsForProvider( nestedContextProvider.title, - providerAndQuery + spaceSegs.length > 1 ? spaceSegs[1] : "" ).then((res) => { setItems(res); }); @@ -316,48 +332,19 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { // Handle slash commands setItems( - props.items?.filter((item) => - item.name.toLowerCase().startsWith(inputValue.toLowerCase()) + availableSlashCommands?.filter((slashCommand) => + slashCommand.name.toLowerCase().startsWith(inputValue.toLowerCase()) ) || [] ); }, [ - props.items, + availableSlashCommands, currentlyInContextQuery, nestedContextProvider, inQueryForContextProvider, ] ); - const onSelectedItemChangeCallback = useCallback( - ({ selectedItem }: any) => { - if (!selectedItem) return; - if (selectedItem.id) { - // Get the query from the input value - const segs = downshiftProps.inputValue.split("@"); - const query = segs[segs.length - 1]; - - // Tell server the context item was selected - client?.selectContextItem(selectedItem.id, query); - if (downshiftProps.inputValue.includes("@")) { - const selectedNestedContextProvider = contextProviders.find( - (provider) => provider.title === selectedItem.id - ); - if ( - !nestedContextProvider && - !selectedNestedContextProvider?.dynamic - ) { - downshiftProps.setInputValue(`@${selectedItem.id} `); - setNestedContextProvider(selectedNestedContextProvider); - } else { - downshiftProps.setInputValue(""); - } - } - } - }, - [nestedContextProvider, contextProviders, client] - ); - const getFilteredContextItemsForProvider = async ( provider: string, query: string @@ -390,7 +377,6 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { }; const { getInputProps, ...downshiftProps } = useCombobox({ - onSelectedItemChange: onSelectedItemChangeCallback, onInputValueChange: onInputValueChangeCallback, items, itemToString(item) { @@ -427,7 +413,6 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { const focusedItemIndex = focusableItemsArray.findIndex( (item) => item === document.activeElement ); - console.log(focusedItemIndex, focusableItems); if (focusedItemIndex === focusableItemsArray.length - 1) { inputRef.current?.focus(); } else if (focusedItemIndex !== -1) { @@ -457,6 +442,13 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { } }, []); + useLayoutEffect(() => { + if (!ulRef.current) { + return; + } + downshiftProps.setHighlightedIndex(0); + }, [items, downshiftProps.setHighlightedIndex, ulRef.current]); + const [metaKeyPressed, setMetaKeyPressed] = useState(false); const [focused, setFocused] = useState(false); useEffect(() => { @@ -476,7 +468,7 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; - }); + }, []); useEffect(() => { if (!inputRef.current) { @@ -489,7 +481,9 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { } else if (event.data.type === "focusContinueInputWithEdit") { inputRef.current!.focus(); - downshiftProps.setInputValue("/edit "); + if (!inputRef.current?.value.startsWith("/edit")) { + downshiftProps.setInputValue("/edit "); + } } }; window.addEventListener("message", handler); @@ -500,21 +494,69 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { const selectContextItemFromDropdown = useCallback( (event: any) => { - const newProviderName = items[downshiftProps.highlightedIndex].name; + const newItem = items[downshiftProps.highlightedIndex]; + const newProviderName = newItem?.name; const newProvider = contextProviders.find( (provider) => provider.display_title === newProviderName ); if (!newProvider) { + if (nestedContextProvider && newItem.id) { + // Tell server the context item was selected + client?.selectContextItem(newItem.id, ""); + + // Clear the input + downshiftProps.setInputValue(""); + setCurrentlyInContextQuery(false); + setNestedContextProvider(undefined); + setInQueryForContextProvider(undefined); + (event.nativeEvent as any).preventDownshiftDefault = true; + event.preventDefault(); + return; + } + // This is a slash command (event.nativeEvent as any).preventDownshiftDefault = true; + event.preventDefault(); return; } else if (newProvider.dynamic && newProvider.requires_query) { + // This is a dynamic context provider that requires a query, like URL / Search setInQueryForContextProvider(newProvider); downshiftProps.setInputValue(`@${newProvider.title} `); (event.nativeEvent as any).preventDownshiftDefault = true; event.preventDefault(); return; } else if (newProvider.dynamic) { + // This is a normal dynamic context provider like Diff or Terminal + if (!newItem.id) return; + + // Get the query from the input value + const segs = downshiftProps.inputValue.split("@"); + const query = segs[segs.length - 1]; + + // Tell server the context item was selected + client?.selectContextItem(newItem.id, query); + if (downshiftProps.inputValue.includes("@")) { + const selectedNestedContextProvider = contextProviders.find( + (provider) => provider.title === newItem.id + ); + if ( + !nestedContextProvider && + !selectedNestedContextProvider?.dynamic + ) { + downshiftProps.setInputValue(`@${newItem.id} `); + setNestedContextProvider(selectedNestedContextProvider); + } else { + downshiftProps.setInputValue(""); + } + } + + // Clear the input + downshiftProps.setInputValue(""); + setCurrentlyInContextQuery(false); + setNestedContextProvider(undefined); + setInQueryForContextProvider(undefined); + (event.nativeEvent as any).preventDownshiftDefault = true; + event.preventDefault(); return; } @@ -531,25 +573,10 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { downshiftProps.highlightedIndex, contextProviders, nestedContextProvider, + downshiftProps.inputValue, ] ); - const showSelectContextGroupDialog = () => { - dispatch(setDialogMessage(<SelectContextGroupDialog />)); - dispatch(setShowDialog(true)); - }; - - const showDialogToSaveContextGroup = () => { - dispatch( - setDialogMessage( - <AddContextGroupDialog - selectedContextItems={props.selectedContextItems} - /> - ) - ); - dispatch(setShowDialog(true)); - }; - const [isComposing, setIsComposing] = useState(false); return ( @@ -558,18 +585,36 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { className="px-2 flex gap-2 items-center flex-wrap mt-2" ref={contextItemsDivRef} > - {props.selectedContextItems.map((item, idx) => { + <HiddenHeaderButtonWithText + className={selectedContextItems.length > 0 ? "pill-button" : ""} + onClick={() => { + client?.deleteContextWithIds( + selectedContextItems.map((item) => item.description.id) + ); + inputRef.current?.focus(); + }} + onKeyDown={(e: any) => { + if (e.key === "Backspace") { + client?.deleteContextWithIds( + selectedContextItems.map((item) => item.description.id) + ); + inputRef.current?.focus(); + } + }} + > + <TrashIcon width="1.4em" height="1.4em" /> + </HiddenHeaderButtonWithText> + {selectedContextItems.map((item, idx) => { return ( <PillButton - areMultipleItems={props.selectedContextItems.length > 1} + areMultipleItems={selectedContextItems.length > 1} key={`${item.description.id.item_id}${idx}`} item={item} - warning={ - item.content.length > 4000 && item.editing - ? "Editing such a large range may be slow" - : undefined + editing={ + item.editing && + (inputRef.current as any)?.value?.startsWith("/edit") } - addingHighlightedCode={props.addingHighlightedCode} + editingAny={(inputRef.current as any)?.value?.startsWith("/edit")} index={idx} onDelete={() => { client?.deleteContextWithIds([item.description.id]); @@ -578,64 +623,16 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { /> ); })} - <HeaderButtonWithText - text="Load bookmarked context" - onClick={() => { - showSelectContextGroupDialog(); - }} - className="pill-button focus:outline-none focus:border-red-600 focus:border focus:border-solid" - onKeyDown={(e: KeyboardEvent) => { - e.preventDefault(); - if (e.key === "Enter") { - showSelectContextGroupDialog(); - } - }} - > - <FolderArrowDownIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> - {props.selectedContextItems.length > 0 && ( - <> - {props.addingHighlightedCode ? ( - <EmptyPillDiv - onClick={() => { - props.onToggleAddContext(); - }} - > - Highlight code section - </EmptyPillDiv> - ) : ( - <HeaderButtonWithText - text="Add more code to context" - onClick={() => { - props.onToggleAddContext(); - }} - className="pill-button focus:outline-none focus:border-red-600 focus:border focus:border-solid" - onKeyDown={(e: KeyboardEvent) => { - e.preventDefault(); - if (e.key === "Enter") { - props.onToggleAddContext(); - } - }} - > - <DocumentPlusIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> - )} - <HeaderButtonWithText - text="Bookmark context" - onClick={() => { - showDialogToSaveContextGroup(); - }} - className="pill-button focus:outline-none focus:border-red-600 focus:border focus:border-solid" - onKeyDown={(e: KeyboardEvent) => { - e.preventDefault(); - if (e.key === "Enter") { - showDialogToSaveContextGroup(); - } - }} - > - <BookmarkIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> - </> + + {selectedContextItems.length > 0 && ( + <HeaderButtonWithText + onClick={() => { + client?.showContextVirtualFile(); + }} + text="View Current Context" + > + <MagnifyingGlassIcon width="1.4em" height="1.4em" /> + </HeaderButtonWithText> )} </div> <div @@ -648,7 +645,7 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { typeof inQueryForContextProvider !== "undefined" } disabled={props.disabled} - placeholder={`Ask a question, type '/' for slash commands, or '@' to add context`} + placeholder={`Ask a question, '/' for slash commands, '@' to add context`} {...getInputProps({ onCompositionStart: () => setIsComposing(true), onCompositionEnd: () => setIsComposing(false), @@ -701,13 +698,15 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { } } setCurrentlyInContextQuery(false); + } else if (event.key === "Enter" && currentlyInContextQuery) { + // Handle "Enter" for Context Providers + selectContextItemFromDropdown(event); } else if ( - event.key === "Enter" && - currentlyInContextQuery && - nestedContextProvider === undefined + event.key === "Tab" && + downshiftProps.isOpen && + items.length > 0 && + items[downshiftProps.highlightedIndex]?.name.startsWith("/") ) { - selectContextItemFromDropdown(event); - } else if (event.key === "Tab" && items.length > 0) { downshiftProps.setInputValue(items[0].name); event.preventDefault(); } else if (event.key === "Tab") { @@ -789,25 +788,10 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { ref: inputRef, })} /> - {inQueryForContextProvider ? ( + {inQueryForContextProvider && ( <DynamicQueryTitleDiv> Enter {inQueryForContextProvider.display_title} Query </DynamicQueryTitleDiv> - ) : ( - <> - <StyledPlusIcon - width="1.4em" - height="1.4em" - data-tooltip-id="add-context-button" - onClick={() => { - downshiftProps.setInputValue("@"); - inputRef.current?.focus(); - }} - /> - <StyledTooltip id="add-context-button" place="bottom"> - Add Context to Prompt - </StyledTooltip> - </> )} <Ul @@ -816,13 +800,17 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { })} showAbove={showAbove()} ulHeightPixels={ulRef.current?.getBoundingClientRect().height || 0} - hidden={!downshiftProps.isOpen || items.length === 0} + hidden={ + !downshiftProps.isOpen || + items.length === 0 || + inputRef.current?.value === "" + } > {nestedContextProvider && ( <div style={{ backgroundColor: secondaryDark, - borderBottom: `1px solid ${lightGray}`, + borderBottom: `0.5px solid ${lightGray}`, display: "flex", gap: "4px", position: "sticky", @@ -846,27 +834,27 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { items.map((item, index) => ( <Li style={{ - borderTop: index === 0 ? "none" : undefined, + borderTop: index === 0 ? "none" : `0.5px solid ${lightGray}`, }} key={`${item.name}${index}`} {...downshiftProps.getItemProps({ item, index })} highlighted={downshiftProps.highlightedIndex === index} selected={downshiftProps.selectedItem === item} onClick={(e) => { - // e.stopPropagation(); - // e.preventDefault(); - // (e.nativeEvent as any).preventDownshiftDefault = true; - // downshiftProps.selectItem(item); selectContextItemFromDropdown(e); - onSelectedItemChangeCallback({ selectedItem: item }); + e.stopPropagation(); + e.preventDefault(); + inputRef.current?.focus(); }} > - <span> + <span className="flex justify-between w-full"> {item.name} {" "} <span style={{ color: lightGray, + float: "right", + textAlign: "right", }} > {item.description} @@ -888,7 +876,7 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { ))} </Ul> </div> - {props.selectedContextItems.length === 0 && + {selectedContextItems.length === 0 && (downshiftProps.inputValue?.startsWith("/edit") || (focused && metaKeyPressed && @@ -897,6 +885,10 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { Inserting at cursor </div> )} + <ContinueButton + disabled={!(inputRef.current as any)?.value} + onClick={() => props.onEnter(undefined)} + /> </> ); }); diff --git a/extension/react-app/src/components/ContinueButton.tsx b/extension/react-app/src/components/ContinueButton.tsx index 10ecd94a..95dde177 100644 --- a/extension/react-app/src/components/ContinueButton.tsx +++ b/extension/react-app/src/components/ContinueButton.tsx @@ -1,26 +1,42 @@ -import styled, { keyframes } from "styled-components"; +import styled from "styled-components"; import { Button } from "."; import { PlayIcon } from "@heroicons/react/24/outline"; import { useSelector } from "react-redux"; import { RootStore } from "../redux/store"; import { useEffect, useState } from "react"; -let StyledButton = styled(Button)<{ color?: string | null }>` +const StyledButton = styled(Button)<{ + color?: string | null; + isDisabled: boolean; +}>` margin: auto; margin-top: 8px; margin-bottom: 16px; display: grid; grid-template-columns: 22px 1fr; align-items: center; - background: ${(props) => props.color || "#be1b55"}; + background-color: ${(props) => props.color || "#be1b55"}; - &:hover { - transition-property: "background"; - opacity: 0.7; + opacity: ${(props) => (props.isDisabled ? 0.5 : 1.0)}; + + cursor: ${(props) => (props.isDisabled ? "default" : "pointer")}; + + &:hover:enabled { + background-color: ${(props) => props.color || "#be1b55"}; + ${(props) => + props.isDisabled + ? "cursor: default;" + : ` + opacity: 0.7; + `} } `; -function ContinueButton(props: { onClick?: () => void; hidden?: boolean }) { +function ContinueButton(props: { + onClick?: () => void; + hidden?: boolean; + disabled: boolean; +}) { const vscMediaUrl = useSelector( (state: RootStore) => state.config.vscMediaUrl ); @@ -49,7 +65,8 @@ function ContinueButton(props: { onClick?: () => void; hidden?: boolean }) { hidden={props.hidden} style={{ fontSize: "10px" }} className="m-auto press-start-2p" - onClick={props.onClick} + onClick={props.disabled ? undefined : props.onClick} + isDisabled={props.disabled} > {vscMediaUrl ? ( <img src={`${vscMediaUrl}/play_button.png`} width="16px" /> diff --git a/extension/react-app/src/components/ErrorStepContainer.tsx b/extension/react-app/src/components/ErrorStepContainer.tsx new file mode 100644 index 00000000..e8ab7950 --- /dev/null +++ b/extension/react-app/src/components/ErrorStepContainer.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import styled from "styled-components"; +import { HistoryNode } from "../../../schema/HistoryNode"; +import { defaultBorderRadius, vscBackground } from "."; +import HeaderButtonWithText from "./HeaderButtonWithText"; +import { + MinusCircleIcon, + MinusIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; + +const Div = styled.div` + padding: 8px; + background-color: #ff000011; + border-radius: ${defaultBorderRadius}; + border: 1px solid #cc0000; +`; + +interface ErrorStepContainerProps { + historyNode: HistoryNode; + onClose: () => void; + onDelete: () => void; +} + +function ErrorStepContainer(props: ErrorStepContainerProps) { + return ( + <div style={{ backgroundColor: vscBackground, position: "relative" }}> + <div + style={{ + position: "absolute", + right: "4px", + top: "4px", + display: "flex", + }} + > + <HeaderButtonWithText text="Collapse" onClick={() => props.onClose()}> + <MinusCircleIcon width="1.3em" height="1.3em" /> + </HeaderButtonWithText> + <HeaderButtonWithText text="Collapse" onClick={() => props.onDelete()}> + <XMarkIcon width="1.3em" height="1.3em" /> + </HeaderButtonWithText> + </div> + <Div> + <pre className="overflow-x-scroll"> + {props.historyNode.observation?.error as string} + </pre> + </Div> + </div> + ); +} + +export default ErrorStepContainer; diff --git a/extension/react-app/src/components/HeaderButtonWithText.tsx b/extension/react-app/src/components/HeaderButtonWithText.tsx index 3122c287..ca359250 100644 --- a/extension/react-app/src/components/HeaderButtonWithText.tsx +++ b/extension/react-app/src/components/HeaderButtonWithText.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { HeaderButton, StyledTooltip } from "."; +import ReactDOM from "react-dom"; interface HeaderButtonWithTextProps { text: string; @@ -14,6 +15,9 @@ interface HeaderButtonWithTextProps { const HeaderButtonWithText = (props: HeaderButtonWithTextProps) => { const [hover, setHover] = useState(false); + + const tooltipPortalDiv = document.getElementById("tooltip-portal-div"); + return ( <> <HeaderButton @@ -34,9 +38,13 @@ const HeaderButtonWithText = (props: HeaderButtonWithTextProps) => { > {props.children} </HeaderButton> - <StyledTooltip id={`header_button_${props.text}`} place="bottom"> - {props.text} - </StyledTooltip> + {tooltipPortalDiv && + ReactDOM.createPortal( + <StyledTooltip id={`header_button_${props.text}`} place="bottom"> + {props.text} + </StyledTooltip>, + tooltipPortalDiv + )} </> ); }; diff --git a/extension/react-app/src/components/Layout.tsx b/extension/react-app/src/components/Layout.tsx index 6410db8a..9ec2e671 100644 --- a/extension/react-app/src/components/Layout.tsx +++ b/extension/react-app/src/components/Layout.tsx @@ -1,7 +1,6 @@ import styled from "styled-components"; import { defaultBorderRadius, secondaryDark, vscForeground } from "."; import { Outlet } from "react-router-dom"; -import Onboarding from "./Onboarding"; import TextDialog from "./TextDialog"; import { useContext, useEffect, useState } from "react"; import { GUIClientContext } from "../App"; @@ -15,10 +14,9 @@ import { import { PlusIcon, FolderIcon, - BookOpenIcon, - ChatBubbleOvalLeftEllipsisIcon, SparklesIcon, Cog6ToothIcon, + QuestionMarkCircleIcon, } from "@heroicons/react/24/outline"; import HeaderButtonWithText from "./HeaderButtonWithText"; import { useNavigate, useLocation } from "react-router-dom"; @@ -62,6 +60,8 @@ const Footer = styled.footer` align-items: center; width: calc(100% - 16px); height: ${FOOTER_HEIGHT}; + + overflow: hidden; `; const GridDiv = styled.div` @@ -98,11 +98,20 @@ const Layout = () => { (state: RootStore) => state.uiState.displayBottomMessageOnBottom ); + const timeline = useSelector( + (state: RootStore) => state.serverState.history.timeline + ); + // #endregion useEffect(() => { const handleKeyDown = (event: any) => { - if (event.metaKey && event.altKey && event.code === "KeyN") { + if ( + event.metaKey && + event.altKey && + event.code === "KeyN" && + timeline.filter((n) => !n.step.hide).length > 0 + ) { client?.loadSession(undefined); } if ((event.metaKey || event.ctrlKey) && event.code === "KeyC") { @@ -121,7 +130,7 @@ const Layout = () => { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [client]); + }, [client, timeline]); return ( <LayoutTopDiv> @@ -133,7 +142,6 @@ const Layout = () => { gridTemplateRows: "1fr auto", }} > - <Onboarding /> <TextDialog showDialog={showDialog} onEnter={() => { @@ -176,54 +184,26 @@ const Layout = () => { color="yellow" /> )} - <ModelSelect /> - {defaultModel === "MaybeProxyOpenAI" && + {defaultModel === "OpenAIFreeTrial" && (location.pathname === "/settings" || - parseInt(localStorage.getItem("freeTrialCounter") || "0") >= - 125) && ( + parseInt(localStorage.getItem("ftc") || "0") >= 125) && ( <ProgressBar - completed={parseInt( - localStorage.getItem("freeTrialCounter") || "0" - )} + completed={parseInt(localStorage.getItem("ftc") || "0")} total={250} /> )} </div> <HeaderButtonWithText + text="Help" onClick={() => { - client?.loadSession(undefined); + navigate("/help"); }} - text="New Session (⌥⌘N)" > - <PlusIcon width="1.4em" height="1.4em" /> + <QuestionMarkCircleIcon width="1.4em" height="1.4em" /> </HeaderButtonWithText> <HeaderButtonWithText onClick={() => { - navigate("/history"); - }} - text="History" - > - <FolderIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> - <a - href="https://continue.dev/docs/how-to-use-continue" - className="no-underline" - > - <HeaderButtonWithText text="Docs"> - <BookOpenIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> - </a> - <a - href="https://github.com/continuedev/continue/issues/new/choose" - className="no-underline" - > - <HeaderButtonWithText text="Feedback"> - <ChatBubbleOvalLeftEllipsisIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> - </a> - <HeaderButtonWithText - onClick={() => { navigate("/settings"); }} text="Settings" @@ -248,6 +228,7 @@ const Layout = () => { {bottomMessage} </BottomMessageDiv> </div> + <div id="tooltip-portal-div" /> </LayoutTopDiv> ); }; diff --git a/extension/react-app/src/components/ModelCard.tsx b/extension/react-app/src/components/ModelCard.tsx new file mode 100644 index 00000000..a537c5f4 --- /dev/null +++ b/extension/react-app/src/components/ModelCard.tsx @@ -0,0 +1,122 @@ +import React, { useContext } from "react"; +import styled from "styled-components"; +import { buttonColor, defaultBorderRadius, lightGray, vscForeground } from "."; +import { setShowDialog } from "../redux/slices/uiStateSlice"; +import { GUIClientContext } from "../App"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { RootStore } from "../redux/store"; +import { BookOpenIcon } from "@heroicons/react/24/outline"; +import HeaderButtonWithText from "./HeaderButtonWithText"; +import ReactDOM from "react-dom"; + +export enum ModelTag { + "Requires API Key" = "Requires API Key", + "Local" = "Local", + "Free" = "Free", + "Open-Source" = "Open-Source", +} + +const MODEL_TAG_COLORS: any = {}; +MODEL_TAG_COLORS[ModelTag["Requires API Key"]] = "#FF0000"; +MODEL_TAG_COLORS[ModelTag["Local"]] = "#00bb00"; +MODEL_TAG_COLORS[ModelTag["Open-Source"]] = "#0033FF"; +MODEL_TAG_COLORS[ModelTag["Free"]] = "#ffff00"; + +export interface ModelInfo { + title: string; + class: string; + args: any; + description: string; + icon?: string; + tags?: ModelTag[]; +} + +const Div = styled.div<{ color: string }>` + border: 1px solid ${lightGray}; + border-radius: ${defaultBorderRadius}; + cursor: pointer; + padding: 4px 8px; + position: relative; + width: 100%; + transition: all 0.5s; + + &:hover { + border: 1px solid ${(props) => props.color}; + background-color: ${(props) => props.color}22; + } +`; + +interface ModelCardProps { + modelInfo: ModelInfo; +} + +function ModelCard(props: ModelCardProps) { + const client = useContext(GUIClientContext); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const vscMediaUrl = useSelector( + (state: RootStore) => state.config.vscMediaUrl + ); + + return ( + <Div + color={buttonColor} + onClick={(e) => { + if ((e.target as any).closest("a")) { + return; + } + client?.addModelForRole( + "*", + props.modelInfo.class, + props.modelInfo.args + ); + dispatch(setShowDialog(false)); + navigate("/"); + }} + > + <div style={{ display: "flex", alignItems: "center" }}> + {vscMediaUrl && ( + <img + src={`${vscMediaUrl}/logos/${props.modelInfo.icon}`} + height="24px" + style={{ marginRight: "10px" }} + /> + )} + <h3>{props.modelInfo.title}</h3> + </div> + {props.modelInfo.tags?.map((tag) => { + return ( + <span + style={{ + backgroundColor: `${MODEL_TAG_COLORS[tag]}55`, + color: "white", + padding: "2px 4px", + borderRadius: defaultBorderRadius, + marginRight: "4px", + }} + > + {tag} + </span> + ); + })} + <p>{props.modelInfo.description}</p> + + <a + style={{ + position: "absolute", + right: "8px", + top: "8px", + }} + href={`https://continue.dev/docs/reference/Models/${props.modelInfo.class.toLowerCase()}`} + target="_blank" + > + <HeaderButtonWithText text="Read the docs"> + <BookOpenIcon width="1.6em" height="1.6em" /> + </HeaderButtonWithText> + </a> + </Div> + ); +} + +export default ModelCard; diff --git a/extension/react-app/src/components/ModelSelect.tsx b/extension/react-app/src/components/ModelSelect.tsx index 0b1829f1..29d9250e 100644 --- a/extension/react-app/src/components/ModelSelect.tsx +++ b/extension/react-app/src/components/ModelSelect.tsx @@ -10,8 +10,9 @@ import { useContext } from "react"; import { GUIClientContext } from "../App"; import { RootStore } from "../redux/store"; import { useDispatch, useSelector } from "react-redux"; -import { PlusIcon } from "@heroicons/react/24/outline"; +import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/24/outline"; import { setDialogMessage, setShowDialog } from "../redux/slices/uiStateSlice"; +import { useNavigate } from "react-router-dom"; const MODEL_INFO: { title: string; class: string; args: any }[] = [ { @@ -83,7 +84,7 @@ const MODEL_INFO: { title: string; class: string; args: any }[] = [ }, { title: "GPT-4 limited free trial", - class: "MaybeProxyOpenAI", + class: "OpenAIFreeTrial", args: { model: "gpt-4", }, @@ -159,10 +160,12 @@ function ModelSelect(props: {}) { const defaultModel = useSelector( (state: RootStore) => (state.serverState.config as any)?.models?.default ); - const unusedModels = useSelector( - (state: RootStore) => (state.serverState.config as any)?.models?.unused + const savedModels = useSelector( + (state: RootStore) => (state.serverState.config as any)?.models?.saved ); + const navigate = useNavigate(); + return ( <GridDiv> <Select @@ -173,7 +176,7 @@ function ModelSelect(props: {}) { defaultValue={0} onChange={(e) => { const value = JSON.parse(e.target.value); - if (value.t === "unused") { + if (value.t === "saved") { client?.setModelForRoleFromIndex("*", value.idx); } }} @@ -188,11 +191,11 @@ function ModelSelect(props: {}) { {modelSelectTitle(defaultModel)} </option> )} - {unusedModels?.map((model: any, idx: number) => { + {savedModels?.map((model: any, idx: number) => { return ( <option value={JSON.stringify({ - t: "unused", + t: "saved", idx, })} > @@ -206,31 +209,7 @@ function ModelSelect(props: {}) { width="1.3em" height="1.3em" onClick={() => { - dispatch( - setDialogMessage( - <div> - <div className="text-lg font-bold p-2"> - Setup a new model provider - </div> - <br /> - {MODEL_INFO.map((model, idx) => { - return ( - <NewProviderDiv - onClick={() => { - const model = MODEL_INFO[idx]; - client?.addModelForRole("*", model.class, model.args); - dispatch(setShowDialog(false)); - }} - > - {model.title} - </NewProviderDiv> - ); - })} - <br /> - </div> - ) - ); - dispatch(setShowDialog(true)); + navigate("/models"); }} /> </GridDiv> diff --git a/extension/react-app/src/components/ModelSettings.tsx b/extension/react-app/src/components/ModelSettings.tsx index 99200502..06516687 100644 --- a/extension/react-app/src/components/ModelSettings.tsx +++ b/extension/react-app/src/components/ModelSettings.tsx @@ -27,7 +27,7 @@ const DefaultModelOptions: { api_key: "", model: "gpt-4", }, - MaybeProxyOpenAI: { + OpenAIFreeTrial: { api_key: "", model: "gpt-4", }, diff --git a/extension/react-app/src/components/Onboarding.tsx b/extension/react-app/src/components/Onboarding.tsx deleted file mode 100644 index 588f7298..00000000 --- a/extension/react-app/src/components/Onboarding.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { useState, useEffect } from "react"; -import styled from "styled-components"; -import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; -import { defaultBorderRadius } from "."; -import Loader from "./Loader"; - -const StyledDiv = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #1e1e1e; - z-index: 200; - - color: white; -`; - -const StyledSpan = styled.span` - padding: 8px; - border-radius: ${defaultBorderRadius}; - &:hover { - background-color: #ffffff33; - } - white-space: nowrap; -`; - -const Onboarding = () => { - const [counter, setCounter] = useState(4); - const gifs = ["intro", "highlight", "question", "help"]; - const topMessages = [ - "Welcome!", - "Highlight code", - "Ask a question", - "Use /help to learn more", - ]; - - useEffect(() => { - const hasVisited = localStorage.getItem("hasVisited"); - if (hasVisited) { - setCounter(4); - } else { - setCounter(0); - localStorage.setItem("hasVisited", "true"); - } - }, []); - - const [loading, setLoading] = useState(true); - - useEffect(() => { - setLoading(true); - }, [counter]); - - return ( - <StyledDiv hidden={counter >= 4}> - <div - style={{ - display: "grid", - justifyContent: "center", - alignItems: "center", - height: "100%", - textAlign: "center", - paddingLeft: "16px", - paddingRight: "16px", - }} - > - <h1>{topMessages[counter]}</h1> - <div style={{ display: "flex", justifyContent: "center" }}> - {loading && ( - <div style={{ margin: "auto", position: "absolute", zIndex: 0 }}> - <Loader /> - </div> - )} - {counter < 4 && - (counter % 2 === 0 ? ( - <img - src={`https://github.com/continuedev/continue/blob/main/media/${gifs[counter]}.gif?raw=true`} - width="100%" - key={"even-gif"} - alt={topMessages[counter]} - onLoad={() => { - setLoading(false); - }} - style={{ zIndex: 1 }} - /> - ) : ( - <img - src={`https://github.com/continuedev/continue/blob/main/media/${gifs[counter]}.gif?raw=true`} - width="100%" - key={"odd-gif"} - alt={topMessages[counter]} - onLoad={() => { - setLoading(false); - }} - style={{ zIndex: 1 }} - /> - ))} - </div> - <p - style={{ - paddingLeft: "50px", - paddingRight: "50px", - paddingBottom: "50px", - textAlign: "center", - cursor: "pointer", - whiteSpace: "nowrap", - }} - > - <StyledSpan - hidden={counter === 0} - onClick={() => setCounter((prev) => Math.max(prev - 1, 0))} - > - <ArrowLeftIcon width="18px" strokeWidth="2px" /> Previous - </StyledSpan> - <span hidden={counter === 0}>{" | "}</span> - <StyledSpan onClick={() => setCounter((prev) => prev + 1)}> - {counter === 0 - ? "Click to learn how to use Continue" - : counter === 3 - ? "Get Started" - : "Next"}{" "} - <ArrowRightIcon width="18px" strokeWidth="2px" /> - </StyledSpan> - </p> - </div> - </StyledDiv> - ); -}; - -export default Onboarding; diff --git a/extension/react-app/src/components/PillButton.tsx b/extension/react-app/src/components/PillButton.tsx index 1ffdeeed..4b602619 100644 --- a/extension/react-app/src/components/PillButton.tsx +++ b/extension/react-app/src/components/PillButton.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import styled from "styled-components"; import { StyledTooltip, @@ -15,13 +15,8 @@ import { } from "@heroicons/react/24/outline"; import { GUIClientContext } from "../App"; import { useDispatch } from "react-redux"; -import { - setBottomMessage, - setBottomMessageCloseTimeout, -} from "../redux/slices/uiStateSlice"; +import { setBottomMessage } from "../redux/slices/uiStateSlice"; import { ContextItem } from "../../../schema/FullState"; -import { ReactMarkdown } from "react-markdown/lib/react-markdown"; -import StyledMarkdownPreview from "./StyledMarkdownPreview"; const Button = styled.button` border: none; @@ -80,33 +75,27 @@ const CircleDiv = styled.div` interface PillButtonProps { onHover?: (arg0: boolean) => void; item: ContextItem; - warning?: string; + editing: boolean; + editingAny: boolean; index: number; - addingHighlightedCode?: boolean; areMultipleItems?: boolean; onDelete?: () => void; } interface StyledButtonProps { - warning: string; + borderColor?: string; editing?: boolean; - areMultipleItems?: boolean; } const StyledButton = styled(Button)<StyledButtonProps>` position: relative; - border-color: ${(props) => - props.warning - ? "red" - : props.editing && props.areMultipleItems - ? vscForeground - : "transparent"}; + border-color: ${(props) => props.borderColor || "transparent"}; border-width: 1px; border-style: solid; &:focus { outline: none; - border-color: ${vscForeground}; + border-color: ${lightGray}; border-width: 1px; border-style: solid; } @@ -116,82 +105,56 @@ const PillButton = (props: PillButtonProps) => { const [isHovered, setIsHovered] = useState(false); const client = useContext(GUIClientContext); - const dispatch = useDispatch(); + const [warning, setWarning] = useState<string | undefined>(undefined); useEffect(() => { - if (isHovered) { - dispatch(setBottomMessageCloseTimeout(undefined)); - dispatch( - setBottomMessage( - <> - <b>{props.item.description.name}</b>:{" "} - {props.item.description.description} - <pre> - <code - style={{ - fontSize: "12px", - backgroundColor: "transparent", - color: vscForeground, - whiteSpace: "pre-wrap", - wordWrap: "break-word", - }} - > - {props.item.content} - </code> - </pre> - </> - ) - ); + if (props.editing && props.item.content.length > 4000) { + setWarning("Editing such a large range may be slow"); } else { - dispatch( - setBottomMessageCloseTimeout( - setTimeout(() => { - if (!isHovered) { - dispatch(setBottomMessage(undefined)); - } - }, 2000) - ) - ); + setWarning(undefined); } - }, [isHovered]); + }, [props.editing, props.item]); + + const dispatch = useDispatch(); return ( - <> - <div style={{ position: "relative" }}> - <StyledButton - areMultipleItems={props.areMultipleItems} - warning={props.warning || ""} - editing={props.item.editing} - onMouseEnter={() => { - setIsHovered(true); - if (props.onHover) { - props.onHover(true); - } - }} - onMouseLeave={() => { - setIsHovered(false); - if (props.onHover) { - props.onHover(false); - } - }} - className="pill-button" - onKeyDown={(e) => { - if (e.key === "Backspace") { - props.onDelete?.(); - } - }} - > - {isHovered && ( - <GridDiv - style={{ - gridTemplateColumns: - props.item.editable && props.areMultipleItems - ? "1fr 1fr" - : "1fr", - backgroundColor: vscBackground, - }} - > - {props.item.editable && props.areMultipleItems && ( + <div style={{ position: "relative" }}> + <StyledButton + borderColor={props.editing ? (warning ? "red" : undefined) : undefined} + onMouseEnter={() => { + setIsHovered(true); + if (props.onHover) { + props.onHover(true); + } + }} + onMouseLeave={() => { + setIsHovered(false); + if (props.onHover) { + props.onHover(false); + } + }} + className="pill-button" + onKeyDown={(e) => { + if (e.key === "Backspace") { + props.onDelete?.(); + } + }} + > + {isHovered && ( + <GridDiv + style={{ + gridTemplateColumns: + props.item.editable && + props.areMultipleItems && + props.editingAny + ? "1fr 1fr" + : "1fr", + backgroundColor: vscBackground, + }} + > + {props.editingAny && + props.item.editable && + props.areMultipleItems && ( <ButtonDiv data-tooltip-id={`edit-${props.index}`} backgroundColor={"#8800aa55"} @@ -205,30 +168,31 @@ const PillButton = (props: PillButtonProps) => { </ButtonDiv> )} - <StyledTooltip id={`pin-${props.index}`}> - Edit this range - </StyledTooltip> - <ButtonDiv - data-tooltip-id={`delete-${props.index}`} - backgroundColor={"#cc000055"} - onClick={() => { - client?.deleteContextWithIds([props.item.description.id]); - dispatch(setBottomMessage(undefined)); - }} - > - <TrashIcon style={{ margin: "auto" }} width="1.6em" /> - </ButtonDiv> - </GridDiv> - )} - {props.item.description.name} - </StyledButton> - <StyledTooltip id={`edit-${props.index}`}> - {props.item.editing - ? "Editing this section (with entire file as context)" - : "Edit this section"} - </StyledTooltip> - <StyledTooltip id={`delete-${props.index}`}>Delete</StyledTooltip> - {props.warning && ( + <StyledTooltip id={`pin-${props.index}`}> + Edit this range + </StyledTooltip> + <ButtonDiv + data-tooltip-id={`delete-${props.index}`} + backgroundColor={"#cc000055"} + onClick={() => { + client?.deleteContextWithIds([props.item.description.id]); + dispatch(setBottomMessage(undefined)); + }} + > + <TrashIcon style={{ margin: "auto" }} width="1.6em" /> + </ButtonDiv> + </GridDiv> + )} + {props.item.description.name} + </StyledButton> + <StyledTooltip id={`edit-${props.index}`}> + {props.item.editing + ? "Editing this section (with entire file as context)" + : "Edit this section"} + </StyledTooltip> + <StyledTooltip id={`delete-${props.index}`}>Delete</StyledTooltip> + {props.editing && + (warning ? ( <> <CircleDiv data-tooltip-id={`circle-div-${props.item.description.name}`} @@ -240,12 +204,32 @@ const PillButton = (props: PillButtonProps) => { /> </CircleDiv> <StyledTooltip id={`circle-div-${props.item.description.name}`}> - {props.warning} + {warning} </StyledTooltip> </> - )} - </div> - </> + ) : ( + <> + <CircleDiv + data-tooltip-id={`circle-div-${props.item.description.name}`} + style={{ + backgroundColor: "#8800aa55", + border: `0.5px solid ${lightGray}`, + padding: "1px", + zIndex: 1, + }} + > + <PaintBrushIcon + style={{ margin: "auto" }} + width="1.0em" + strokeWidth={2} + /> + </CircleDiv> + <StyledTooltip id={`circle-div-${props.item.description.name}`}> + Editing this range + </StyledTooltip> + </> + ))} + </div> ); }; diff --git a/extension/react-app/src/components/ProgressBar.tsx b/extension/react-app/src/components/ProgressBar.tsx index 4efee776..27972ffc 100644 --- a/extension/react-app/src/components/ProgressBar.tsx +++ b/extension/react-app/src/components/ProgressBar.tsx @@ -28,9 +28,12 @@ const GridDiv = styled.div` const P = styled.p` margin: 0; margin-top: 2px; - font-size: 12px; + font-size: 11.5px; color: ${lightGray}; text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; `; interface ProgressBarProps { @@ -45,7 +48,7 @@ const ProgressBar = ({ completed, total }: ProgressBarProps) => { <> <a href="https://continue.dev/docs/customization/models" - className="no-underline" + className="no-underline ml-2" > <GridDiv data-tooltip-id="usage_progress_bar"> <ProgressBarWrapper> @@ -61,7 +64,7 @@ const ProgressBar = ({ completed, total }: ProgressBarProps) => { /> </ProgressBarWrapper> <P> - Free Usage: {completed} / {total} + Free Uses: {completed} / {total} </P> </GridDiv> </a> diff --git a/extension/react-app/src/components/StepContainer.tsx b/extension/react-app/src/components/StepContainer.tsx index a05aefb0..61529227 100644 --- a/extension/react-app/src/components/StepContainer.tsx +++ b/extension/react-app/src/components/StepContainer.tsx @@ -1,18 +1,9 @@ -import { useContext, useEffect, useRef, useState } from "react"; -import styled, { keyframes } from "styled-components"; -import { secondaryDark, vscBackground } from "."; -import { - ChevronDownIcon, - ChevronRightIcon, - ArrowPathIcon, - XMarkIcon, - MagnifyingGlassIcon, - StopCircleIcon, -} from "@heroicons/react/24/outline"; +import { useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import { defaultBorderRadius, secondaryDark, vscBackground } from "."; +import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { HistoryNode } from "../../../schema/HistoryNode"; import HeaderButtonWithText from "./HeaderButtonWithText"; -import { getMetaKeyLabel, isMetaEquivalentKeyPressed } from "../util"; -import { GUIClientContext } from "../App"; import StyledMarkdownPreview from "./StyledMarkdownPreview"; interface StepContainerProps { @@ -23,11 +14,10 @@ interface StepContainerProps { onRetry: () => void; onDelete: () => void; open: boolean; - onToggleAll: () => void; - onToggle: () => void; isFirst: boolean; isLast: boolean; index: number; + noUserInputParent: boolean; } // #region styled components @@ -35,74 +25,30 @@ interface StepContainerProps { const MainDiv = styled.div<{ stepDepth: number; inFuture: boolean; -}>` - opacity: ${(props) => (props.inFuture ? 0.3 : 1)}; - overflow: hidden; - margin-left: 0px; - margin-right: 0px; -`; +}>``; -const HeaderDiv = styled.div<{ error: boolean; loading: boolean }>` - background-color: ${(props) => (props.error ? "#522" : vscBackground)}; - display: grid; - grid-template-columns: 1fr auto auto; +const ButtonsDiv = styled.div` + display: flex; + gap: 2px; align-items: center; - padding-right: 8px; -`; + background-color: ${vscBackground}; + box-shadow: 1px 1px 10px ${vscBackground}; + border-radius: ${defaultBorderRadius}; -const LeftHeaderSubDiv = styled.div` - margin: 8px; - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - grid-gap: 2px; + position: absolute; + right: 0; + top: 0; + height: 0; `; const ContentDiv = styled.div<{ isUserInput: boolean }>` - padding-left: 4px; - padding-right: 2px; + padding: 2px; + padding-right: 0px; background-color: ${(props) => props.isUserInput ? secondaryDark : vscBackground}; font-size: 13px; -`; - -const gradient = keyframes` - 0% { - background-position: 0px 0; - } - 100% { - background-position: 100em 0; - } -`; - -const GradientBorder = styled.div<{ - borderWidth?: number; - borderRadius?: string; - borderColor?: string; - isFirst: boolean; - isLast: boolean; - loading: boolean; -}>` - border-radius: ${(props) => props.borderRadius || "0"}; - padding-top: ${(props) => - `${(props.borderWidth || 1) / (props.isFirst ? 1 : 2)}px`}; - padding-bottom: ${(props) => - `${(props.borderWidth || 1) / (props.isLast ? 1 : 2)}px`}; - background: ${(props) => - props.borderColor - ? props.borderColor - : `repeating-linear-gradient( - 101.79deg, - #1BBE84 0%, - #331BBE 16%, - #BE1B55 33%, - #A6BE1B 55%, - #BE1B55 67%, - #331BBE 85%, - #1BBE84 99% - )`}; - animation: ${(props) => (props.loading ? gradient : "")} 6s linear infinite; - background-size: 200% 200%; + border-radius: ${defaultBorderRadius}; + overflow: hidden; `; // #endregion @@ -112,7 +58,6 @@ function StepContainer(props: StepContainerProps) { const naturalLanguageInputRef = useRef<HTMLTextAreaElement>(null); const userInputRef = useRef<HTMLInputElement>(null); const isUserInput = props.historyNode.step.name === "UserInputStep"; - const client = useContext(GUIClientContext); useEffect(() => { if (userInputRef?.current) { @@ -139,91 +84,11 @@ function StepContainer(props: StepContainerProps) { hidden={props.historyNode.step.hide as any} > <div> - <GradientBorder - loading={props.historyNode.active as boolean} - isFirst={props.isFirst} - isLast={props.isLast} - borderColor={ - props.historyNode.observation?.error - ? "#f005" - : props.historyNode.active - ? undefined - : "transparent" - } - className="overflow-hidden cursor-pointer" - onClick={(e) => { - if (isMetaEquivalentKeyPressed(e)) { - props.onToggleAll(); - } else { - props.onToggle(); - } - }} - > - <HeaderDiv - loading={(props.historyNode.active as boolean) || false} - error={props.historyNode.observation?.error ? true : false} - > - <LeftHeaderSubDiv - style={ - props.historyNode.observation?.error ? { color: "white" } : {} - } - > - {!isUserInput && - (props.open ? ( - <ChevronDownIcon width="1.4em" height="1.4em" /> - ) : ( - <ChevronRightIcon width="1.4em" height="1.4em" /> - ))} - {props.historyNode.observation?.title || - (props.historyNode.step.name as any)} - </LeftHeaderSubDiv> - {/* <HeaderButton - onClick={(e) => { - e.stopPropagation(); - props.onReverse(); - }} - > - <Backward size="1.6em" onClick={props.onReverse}></Backward> - </HeaderButton> */} - {(isHovered || (props.historyNode.active as boolean)) && ( - <div className="flex gap-2 items-center"> - {(props.historyNode.logs as any)?.length > 0 && ( - <HeaderButtonWithText - text="Logs" - onClick={(e) => { - e.stopPropagation(); - client?.showLogsAtIndex(props.index); - }} - > - <MagnifyingGlassIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> - )} - <HeaderButtonWithText - onClick={(e) => { - e.stopPropagation(); - props.onDelete(); - }} - text={ - props.historyNode.active - ? `Stop (${getMetaKeyLabel()}⌫)` - : "Delete" - } - > - {props.historyNode.active ? ( - <StopCircleIcon - width="1.4em" - height="1.4em" - onClick={props.onDelete} - /> - ) : ( - <XMarkIcon - width="1.4em" - height="1.4em" - onClick={props.onDelete} - /> - )} - </HeaderButtonWithText> - {props.historyNode.observation?.error ? ( + {isHovered && + (props.historyNode.observation?.error || props.noUserInputParent) && ( + <ButtonsDiv> + {props.historyNode.observation?.error && + (( <HeaderButtonWithText text="Retry" onClick={(e) => { @@ -237,39 +102,33 @@ function StepContainer(props: StepContainerProps) { onClick={props.onRetry} /> </HeaderButtonWithText> - ) : ( - <></> - )} - </div> - )} - </HeaderDiv> - </GradientBorder> - <ContentDiv hidden={!props.open} isUserInput={isUserInput}> - {props.open && false && ( - <> - <pre className="overflow-x-scroll"> - Step Details: - <br /> - {JSON.stringify(props.historyNode.step, null, 2)} - </pre> - </> - )} + ) as any)} - {props.historyNode.observation?.error ? ( - <details> - <summary>View Traceback</summary> - <pre className="overflow-x-scroll"> - {props.historyNode.observation.error as string} - </pre> - </details> - ) : ( - <StyledMarkdownPreview - source={props.historyNode.step.description || ""} - wrapperElement={{ - "data-color-mode": "dark", - }} - /> + {props.noUserInputParent && ( + <HeaderButtonWithText + text="Delete" + onClick={(e) => { + e.stopPropagation(); + props.onDelete(); + }} + > + <XMarkIcon + width="1.4em" + height="1.4em" + onClick={props.onRetry} + /> + </HeaderButtonWithText> + )} + </ButtonsDiv> )} + + <ContentDiv hidden={!props.open} isUserInput={isUserInput}> + <StyledMarkdownPreview + source={props.historyNode.step.description || ""} + wrapperElement={{ + "data-color-mode": "dark", + }} + /> </ContentDiv> </div> </MainDiv> diff --git a/extension/react-app/src/components/Suggestions.tsx b/extension/react-app/src/components/Suggestions.tsx new file mode 100644 index 00000000..1709288c --- /dev/null +++ b/extension/react-app/src/components/Suggestions.tsx @@ -0,0 +1,228 @@ +import React, { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; +import { + StyledTooltip, + defaultBorderRadius, + lightGray, + secondaryDark, + vscForeground, +} from "."; +import { + PaperAirplaneIcon, + SparklesIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import { useSelector } from "react-redux"; +import { RootStore } from "../redux/store"; +import HeaderButtonWithText from "./HeaderButtonWithText"; + +const Div = styled.div<{ isDisabled: boolean }>` + border-radius: ${defaultBorderRadius}; + cursor: ${(props) => (props.isDisabled ? "not-allowed" : "pointer")}; + padding: 8px 8px; + background-color: ${secondaryDark}; + border: 1px solid transparent; + + display: flex; + justify-content: space-between; + align-items: center; + + color: ${(props) => (props.isDisabled ? lightGray : vscForeground)}; + + &:hover { + border: ${(props) => + props.isDisabled ? "1px solid transparent" : `1px solid ${lightGray}`}; + } +`; + +const P = styled.p` + font-size: 13px; + margin: 0; +`; + +interface SuggestionsDivProps { + title: string; + description: string; + textInput: string; + onClick?: () => void; + disabled: boolean; +} + +function SuggestionsDiv(props: SuggestionsDivProps) { + const [isHovered, setIsHovered] = useState(false); + + return ( + <> + <Div + data-tooltip-id={`suggestion-disabled-${props.textInput.replace( + " ", + "" + )}`} + onClick={props.onClick} + onMouseEnter={() => { + if (props.disabled) return; + setIsHovered(true); + }} + onMouseLeave={() => setIsHovered(false)} + isDisabled={props.disabled} + > + <P>{props.description}</P> + <PaperAirplaneIcon + width="1.6em" + height="1.6em" + style={{ + opacity: isHovered ? 1 : 0, + backgroundColor: secondaryDark, + boxShadow: `1px 1px 10px ${secondaryDark}`, + borderRadius: defaultBorderRadius, + }} + /> + </Div> + <StyledTooltip + id={`suggestion-disabled-${props.textInput.replace(" ", "")}`} + place="bottom" + hidden={!props.disabled} + > + Must highlight code first + </StyledTooltip> + </> + ); +} + +const stageDescriptions = [ + <p>Ask a question</p>, + <ol> + <li>Highlight code in the editor</li> + <li>Press cmd+M to select the code</li> + <li>Ask a question</li> + </ol>, + <ol> + <li>Highlight code in the editor</li> + <li>Press cmd+shift+M to select the code</li> + <li>Request and edit</li> + </ol>, +]; + +const suggestionsStages: any[][] = [ + [ + { + title: stageDescriptions[0], + description: "How does merge sort work?", + textInput: "How does merge sort work?", + }, + { + title: stageDescriptions[0], + description: "How do I sum over a column in SQL?", + textInput: "How do I sum over a column in SQL?", + }, + ], + [ + { + title: stageDescriptions[1], + description: "Is there any way to make this code more efficient?", + textInput: "Is there any way to make this code more efficient?", + }, + { + title: stageDescriptions[1], + description: "What does this function do?", + textInput: "What does this function do?", + }, + ], + [ + { + title: stageDescriptions[2], + description: "/edit write comments for this code", + textInput: "/edit write comments for this code", + }, + { + title: stageDescriptions[2], + description: "/edit make this code more efficient", + textInput: "/edit make this code more efficient", + }, + ], +]; + +const TutorialDiv = styled.div` + margin: 4px; + position: relative; + background-color: #ff02; + border-radius: ${defaultBorderRadius}; + padding: 8px 4px; +`; + +function SuggestionsArea(props: { onClick: (textInput: string) => void }) { + const [stage, setStage] = useState( + parseInt(localStorage.getItem("stage") || "0") + ); + const timeline = useSelector( + (state: RootStore) => state.serverState.history.timeline + ); + const sessionId = useSelector( + (state: RootStore) => state.serverState.session_info?.session_id + ); + const codeIsHighlighted = useSelector((state: RootStore) => + state.serverState.selected_context_items.some( + (item) => item.description.id.provider_title === "code" + ) + ); + + const [hide, setHide] = useState(false); + + useEffect(() => { + setHide(false); + }, [sessionId]); + + const [numTutorialInputs, setNumTutorialInputs] = useState(0); + + const inputsAreOnlyTutorial = useCallback(() => { + const inputs = timeline.filter( + (node) => !node.step.hide && node.step.name === "User Input" + ); + return inputs.length - numTutorialInputs === 0; + }, [timeline, numTutorialInputs]); + + return ( + <> + {hide || stage > 2 || !inputsAreOnlyTutorial() || ( + <TutorialDiv> + <div className="flex"> + <SparklesIcon width="1.3em" height="1.3em" color="yellow" /> + <b className="ml-1">Tutorial</b> + </div> + <p style={{ color: lightGray }}> + {stage < suggestionsStages.length && + suggestionsStages[stage][0]?.title} + </p> + <HeaderButtonWithText + className="absolute right-1 top-1 cursor-pointer" + text="Close Tutorial" + onClick={() => { + console.log("HIDE"); + setHide(true); + }} + > + <XMarkIcon width="1.2em" height="1.2em" /> + </HeaderButtonWithText> + <div className="grid grid-cols-2 gap-2 mt-2"> + {suggestionsStages[stage]?.map((suggestion) => ( + <SuggestionsDiv + disabled={stage > 0 && !codeIsHighlighted} + {...suggestion} + onClick={() => { + if (stage > 0 && !codeIsHighlighted) return; + props.onClick(suggestion.textInput); + setStage(stage + 1); + localStorage.setItem("stage", (stage + 1).toString()); + setHide(true); + setNumTutorialInputs((prev) => prev + 1); + }} + /> + ))} + </div> + </TutorialDiv> + )} + </> + ); +} + +export default SuggestionsArea; diff --git a/extension/react-app/src/components/TimelineItem.tsx b/extension/react-app/src/components/TimelineItem.tsx new file mode 100644 index 00000000..78568890 --- /dev/null +++ b/extension/react-app/src/components/TimelineItem.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { lightGray, secondaryDark, vscBackground } from "."; +import styled from "styled-components"; +import { ChatBubbleOvalLeftIcon, PlusIcon } from "@heroicons/react/24/outline"; + +const CollapseButton = styled.div` + background-color: ${vscBackground}; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + flex-grow: 0; + margin-left: 5px; + cursor: pointer; +`; + +const CollapsedDiv = styled.div` + margin-top: 8px; + margin-bottom: 8px; + margin-left: 8px; + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + min-height: 16px; +`; + +interface TimelineItemProps { + historyNode: any; + open: boolean; + onToggle: () => void; + children: any; + iconElement?: any; +} + +function TimelineItem(props: TimelineItemProps) { + return props.open ? ( + props.children + ) : ( + <CollapsedDiv> + <CollapseButton + onClick={() => { + props.onToggle(); + }} + > + {props.iconElement || ( + <ChatBubbleOvalLeftIcon width="16px" height="16px" /> + )} + </CollapseButton> + <span style={{ color: lightGray }}> + {props.historyNode.observation?.error + ? props.historyNode.observation?.title + : props.historyNode.step.name} + </span> + </CollapsedDiv> + ); +} + +export default TimelineItem; diff --git a/extension/react-app/src/components/UserInputContainer.tsx b/extension/react-app/src/components/UserInputContainer.tsx index 866fef58..76a3c615 100644 --- a/extension/react-app/src/components/UserInputContainer.tsx +++ b/extension/react-app/src/components/UserInputContainer.tsx @@ -1,5 +1,11 @@ -import React, { useContext, useEffect, useRef, useState } from "react"; -import styled from "styled-components"; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import styled, { keyframes } from "styled-components"; import { defaultBorderRadius, lightGray, @@ -8,69 +14,115 @@ import { vscForeground, } from "."; import HeaderButtonWithText from "./HeaderButtonWithText"; -import { XMarkIcon, CheckIcon } from "@heroicons/react/24/outline"; +import { + XMarkIcon, + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + MagnifyingGlassIcon, + StopCircleIcon, +} from "@heroicons/react/24/outline"; import { HistoryNode } from "../../../schema/HistoryNode"; import { GUIClientContext } from "../App"; +import { getMetaKeyLabel, isMetaEquivalentKeyPressed } from "../util"; +import { RootStore } from "../redux/store"; +import { useSelector } from "react-redux"; interface UserInputContainerProps { onDelete: () => void; children: string; historyNode: HistoryNode; index: number; + onToggle: (arg0: boolean) => void; + onToggleAll: (arg0: boolean) => void; + isToggleOpen: boolean; + active: boolean; + groupIndices: number[]; } -const StyledDiv = styled.div` - position: relative; - background-color: ${secondaryDark}; - font-size: 13px; +const gradient = keyframes` + 0% { + background-position: 0px 0; + } + 100% { + background-position: 100em 0; + } +`; + +const ToggleDiv = styled.div` display: flex; align-items: center; - border-bottom: 1px solid ${vscBackground}; - padding: 8px; - padding-top: 0px; - padding-bottom: 0px; + justify-content: center; + cursor: pointer; - border-bottom: 0.5px solid ${lightGray}; - border-top: 0.5px solid ${lightGray}; -`; + height: 100%; + padding: 0 4px; -const DeleteButtonDiv = styled.div` - position: absolute; - top: 8px; - right: 8px; + &:hover { + background-color: ${vscBackground}; + } `; -const StyledPre = styled.pre` - margin-right: 22px; - margin-left: 8px; - white-space: pre-wrap; - word-wrap: break-word; - font-family: "Lexend", sans-serif; - font-size: 13px; +const GradientBorder = styled.div<{ + borderWidth?: number; + borderRadius?: string; + borderColor?: string; + isFirst: boolean; + isLast: boolean; + loading: boolean; +}>` + border-radius: ${(props) => props.borderRadius || "0"}; + padding: ${(props) => + `${(props.borderWidth || 1) / (props.isFirst ? 1 : 2)}px`}; + background: ${(props) => + props.borderColor + ? props.borderColor + : `repeating-linear-gradient( + 101.79deg, + #1BBE84 0%, + #331BBE 16%, + #BE1B55 33%, + #A6BE1B 55%, + #BE1B55 67%, + #331BBE 85%, + #1BBE84 99% + )`}; + animation: ${(props) => (props.loading ? gradient : "")} 6s linear infinite; + background-size: 200% 200%; `; -const TextArea = styled.textarea` - margin: 8px; - margin-right: 22px; - padding: 8px; - white-space: pre-wrap; - word-wrap: break-word; - font-family: "Lexend", sans-serif; +const StyledDiv = styled.div<{ editing: boolean }>` font-size: 13px; - width: 100%; + font-family: inherit; border-radius: ${defaultBorderRadius}; - height: 100%; - border: none; - background-color: ${vscBackground}; - resize: none; - outline: none; - border: none; + height: auto; + background-color: ${secondaryDark}; color: ${vscForeground}; + align-items: center; + position: relative; + z-index: 1; + overflow: hidden; + display: grid; + grid-template-columns: auto 1fr; - &:focus { - border: none; - outline: none; - } + outline: ${(props) => (props.editing ? `1px solid ${lightGray}` : "none")}; + cursor: text; +`; + +const DeleteButtonDiv = styled.div` + position: absolute; + top: 8px; + right: 8px; + background-color: ${secondaryDark}; + box-shadow: 2px 2px 10px ${secondaryDark}; + border-radius: ${defaultBorderRadius}; +`; + +const GridDiv = styled.div` + display: grid; + grid-template-columns: auto 1fr; + grid-gap: 8px; + align-items: center; `; function stringWithEllipsis(str: string, maxLen: number) { @@ -84,108 +136,194 @@ const UserInputContainer = (props: UserInputContainerProps) => { const [isHovered, setIsHovered] = useState(false); const [isEditing, setIsEditing] = useState(false); - const textAreaRef = useRef<HTMLTextAreaElement>(null); + const divRef = useRef<HTMLDivElement>(null); const client = useContext(GUIClientContext); + const [prevContent, setPrevContent] = useState(""); + + const history = useSelector((state: RootStore) => state.serverState.history); + useEffect(() => { - if (isEditing && textAreaRef.current) { - textAreaRef.current.focus(); - // Select all text - textAreaRef.current.setSelectionRange( - 0, - textAreaRef.current.value.length - ); - // Change the size to match the contents (up to a max) - textAreaRef.current.style.height = "auto"; - textAreaRef.current.style.height = - (textAreaRef.current.scrollHeight > 500 - ? 500 - : textAreaRef.current.scrollHeight) + "px"; + if (isEditing && divRef.current) { + setPrevContent(divRef.current.innerText); + divRef.current.focus(); + + if (divRef.current.innerText !== "") { + const range = document.createRange(); + const sel = window.getSelection(); + range.setStart(divRef.current, 0); + range.setEnd(divRef.current, 1); + sel?.removeAllRanges(); + sel?.addRange(range); + } } - }, [isEditing]); + }, [isEditing, divRef.current]); + + const onBlur = useCallback(() => { + setIsEditing(false); + if (divRef.current) { + divRef.current.innerText = prevContent; + divRef.current.blur(); + } + }, [divRef.current]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - setIsEditing(false); + onBlur(); } }; - document.addEventListener("keydown", handleKeyDown); + divRef.current?.addEventListener("keydown", handleKeyDown); return () => { - document.removeEventListener("keydown", handleKeyDown); + divRef.current?.removeEventListener("keydown", handleKeyDown); }; - }, []); + }, [prevContent, divRef.current, isEditing, onBlur]); const doneEditing = (e: any) => { - if (!textAreaRef.current?.value) { + if (!divRef.current?.innerText) { return; } - client?.editStepAtIndex(textAreaRef.current.value, props.index); + setPrevContent(divRef.current.innerText); + client?.editStepAtIndex(divRef.current.innerText, props.index); setIsEditing(false); e.stopPropagation(); + divRef.current?.blur(); }; return ( - <StyledDiv - onMouseEnter={() => { - setIsHovered(true); - }} - onMouseLeave={() => { - setIsHovered(false); - }} + <GradientBorder + loading={props.active} + isFirst={false} + isLast={false} + borderColor={props.active ? undefined : vscBackground} + borderRadius={defaultBorderRadius} > - {isEditing ? ( - <TextArea - ref={textAreaRef} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - doneEditing(e); + <StyledDiv + editing={isEditing} + onMouseEnter={() => { + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + onClick={() => { + setIsEditing(true); + }} + > + <GridDiv> + <ToggleDiv + onClick={ + props.isToggleOpen + ? (e) => { + e.stopPropagation(); + if (isMetaEquivalentKeyPressed(e)) { + props.onToggleAll(false); + } else { + props.onToggle(false); + } + } + : (e) => { + e.stopPropagation(); + if (isMetaEquivalentKeyPressed(e)) { + props.onToggleAll(true); + } else { + props.onToggle(true); + } + } } - }} - defaultValue={props.children} - onBlur={() => { - setIsEditing(false); - }} - /> - ) : ( - <StyledPre - onClick={() => { - setIsEditing(true); - }} - className="mr-6 cursor-text w-full" - > - {stringWithEllipsis(props.children, 600)} - </StyledPre> - )} - {/* <ReactMarkdown children={props.children} className="w-fit mr-10" /> */} - <DeleteButtonDiv> - {(isHovered || isEditing) && ( - <div className="flex"> - {isEditing ? ( - <HeaderButtonWithText - onClick={(e) => { - doneEditing(e); - }} - text="Done" - > - <CheckIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> + > + {props.isToggleOpen ? ( + <ChevronDownIcon width="1.4em" height="1.4em" /> ) : ( - <HeaderButtonWithText - onClick={(e) => { - props.onDelete(); - e.stopPropagation(); - }} - text="Delete" - > - <XMarkIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> + <ChevronRightIcon width="1.4em" height="1.4em" /> )} + </ToggleDiv> + <div + style={{ + padding: "8px", + paddingTop: "4px", + paddingBottom: "4px", + }} + > + <div + ref={divRef} + onBlur={() => { + onBlur(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + doneEditing(e); + } + }} + contentEditable={true} + suppressContentEditableWarning={true} + className="mr-6 ml-1 cursor-text w-full py-2 flex items-center content-center outline-none" + > + {isEditing + ? props.children + : stringWithEllipsis(props.children, 600)} + </div> + <DeleteButtonDiv> + {(isHovered || isEditing) && ( + <div className="flex"> + {isEditing ? ( + <HeaderButtonWithText + onClick={(e) => { + doneEditing(e); + }} + text="Done" + > + <CheckIcon width="1.4em" height="1.4em" /> + </HeaderButtonWithText> + ) : ( + <> + {history.timeline + .filter( + (h, i: number) => + props.groupIndices.includes(i) && h.logs + ) + .some((h) => h.logs!.length > 0) && ( + <HeaderButtonWithText + onClick={(e) => { + e.stopPropagation(); + client?.showLogsAtIndex(props.groupIndices[1]); + }} + text="Context Used" + > + <MagnifyingGlassIcon width="1.4em" height="1.4em" /> + </HeaderButtonWithText> + )} + <HeaderButtonWithText + onClick={(e) => { + e.stopPropagation(); + if (props.active) { + client?.deleteAtIndex(props.groupIndices[1]); + } else { + props.onDelete(); + } + }} + text={ + props.active + ? `Stop (${getMetaKeyLabel()}⌫)` + : "Delete" + } + > + {props.active ? ( + <StopCircleIcon width="1.4em" height="1.4em" /> + ) : ( + <XMarkIcon width="1.4em" height="1.4em" /> + )} + </HeaderButtonWithText> + </> + )} + </div> + )} + </DeleteButtonDiv> </div> - )} - </DeleteButtonDiv> - </StyledDiv> + </GridDiv> + </StyledDiv> + </GradientBorder> ); }; export default UserInputContainer; diff --git a/extension/react-app/src/components/dialogs/FTCDialog.tsx b/extension/react-app/src/components/dialogs/FTCDialog.tsx new file mode 100644 index 00000000..3ea753bc --- /dev/null +++ b/extension/react-app/src/components/dialogs/FTCDialog.tsx @@ -0,0 +1,72 @@ +import React, { useContext } from "react"; +import styled from "styled-components"; +import { Button, TextInput } from ".."; +import { useNavigate } from "react-router-dom"; +import { GUIClientContext } from "../../App"; +import { useDispatch } from "react-redux"; +import { setShowDialog } from "../../redux/slices/uiStateSlice"; + +const GridDiv = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 8px; + align-items: center; +`; + +function FTCDialog() { + const navigate = useNavigate(); + const [apiKey, setApiKey] = React.useState(""); + const client = useContext(GUIClientContext); + const dispatch = useDispatch(); + + return ( + <div className="p-4"> + <h3>Free Trial Limit Reached</h3> + <p> + You've reached the free trial limit of 250 free inputs with Continue's + OpenAI API key. To keep using Continue, you can either use your own API + key, or use a local LLM. To read more about the options, see our{" "} + <a + href="https://continue.dev/docs/customization/models" + target="_blank" + > + documentation + </a> + . If you're just looking for fastest way to keep going, type '/config' + to open your Continue config file and paste your API key into the + OpenAIFreeTrial object. + </p> + + <TextInput + type="text" + placeholder="Enter your OpenAI API key" + value={apiKey} + onChange={(e) => setApiKey(e.target.value)} + /> + <GridDiv> + <Button + onClick={() => { + navigate("/models"); + }} + > + Select model + </Button> + <Button + disabled={!apiKey} + onClick={() => { + client?.addModelForRole("*", "OpenAI", { + model: "gpt-4", + api_key: apiKey, + title: "GPT-4", + }); + dispatch(setShowDialog(false)); + }} + > + Use my API key + </Button> + </GridDiv> + </div> + ); +} + +export default FTCDialog; diff --git a/extension/react-app/src/components/dialogs/KeyboardShortcuts.tsx b/extension/react-app/src/components/dialogs/KeyboardShortcuts.tsx new file mode 100644 index 00000000..2a7b735c --- /dev/null +++ b/extension/react-app/src/components/dialogs/KeyboardShortcuts.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import styled from "styled-components"; +import { + defaultBorderRadius, + lightGray, + secondaryDark, + vscForeground, +} from ".."; +import { getPlatform } from "../../util"; + +const GridDiv = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-gap: 2rem; + padding: 1rem; + justify-items: center; + align-items: center; + + border-top: 0.5px solid ${lightGray}; +`; + +const KeyDiv = styled.div` + border: 0.5px solid ${lightGray}; + border-radius: ${defaultBorderRadius}; + padding: 4px; + color: ${vscForeground}; + + width: 16px; + height: 16px; + + display: flex; + justify-content: center; + align-items: center; +`; + +interface KeyboardShortcutProps { + mac: string; + windows: string; + description: string; +} + +function KeyboardShortcut(props: KeyboardShortcutProps) { + const shortcut = getPlatform() === "windows" ? props.windows : props.mac; + return ( + <div className="flex justify-between w-full items-center"> + <span + style={{ + color: vscForeground, + }} + > + {props.description} + </span> + <div className="flex gap-2 float-right"> + {shortcut.split(" ").map((key) => { + return <KeyDiv>{key}</KeyDiv>; + })} + </div> + </div> + ); +} + +const shortcuts: KeyboardShortcutProps[] = [ + { + mac: "⌘ M", + windows: "⌃ M", + description: "Ask about Highlighted Code", + }, + { + mac: "⌘ ⇧ M", + windows: "⌃ ⇧ M", + description: "Edit Highlighted Code", + }, + { + mac: "⌘ ⇧ ↵", + windows: "⌃ ⇧ ↵", + description: "Accept Diff", + }, + { + mac: "⌘ ⇧ ⌫", + windows: "⌃ ⇧ ⌫", + description: "Reject Diff", + }, + { + mac: "⌘ ⇧ L", + windows: "⌃ ⇧ L", + description: "Quick Text Entry", + }, + { + mac: "⌥ ⌘ M", + windows: "⌥ ⌃ M", + description: "Toggle Auxiliary Bar", + }, + { + mac: "⌘ ⇧ R", + windows: "⌃ ⇧ R", + description: "Debug Terminal", + }, + { + mac: "⌥ ⌘ N", + windows: "⌥ ⌃ N", + description: "New Session", + }, + { + mac: "⌘ ⌫", + windows: "⌃ ⌫", + description: "Stop Active Step", + }, +]; + +function KeyboardShortcutsDialog() { + return ( + <div className="p-2"> + <h3 className="my-3 mx-auto text-center">Keyboard Shortcuts</h3> + <GridDiv> + {shortcuts.map((shortcut) => { + return ( + <KeyboardShortcut + mac={shortcut.mac} + windows={shortcut.windows} + description={shortcut.description} + /> + ); + })} + </GridDiv> + </div> + ); +} + +export default KeyboardShortcutsDialog; diff --git a/extension/react-app/src/components/index.ts b/extension/react-app/src/components/index.ts index 1f418c94..6f5a2f37 100644 --- a/extension/react-app/src/components/index.ts +++ b/extension/react-app/src/components/index.ts @@ -7,7 +7,7 @@ export const lightGray = "#646464"; // export const vscBackground = "rgb(30 30 30)"; export const vscBackgroundTransparent = "#1e1e1ede"; export const buttonColor = "#1bbe84"; -export const buttonColorHover = "1bbe84a8"; +export const buttonColorHover = "#1bbe84a8"; export const secondaryDark = "var(--vscode-list-hoverBackground)"; export const vscBackground = "var(--vscode-editor-background)"; @@ -17,7 +17,6 @@ export const Button = styled.button` padding: 10px 12px; margin: 8px 0; border-radius: ${defaultBorderRadius}; - cursor: pointer; border: none; color: white; @@ -28,7 +27,7 @@ export const Button = styled.button` } &:hover:enabled { - background-color: ${buttonColorHover}; + cursor: pointer; } `; @@ -56,6 +55,8 @@ export const TextArea = styled.textarea` z-index: 1; border: 1px solid transparent; + resize: vertical; + &:focus { outline: 1px solid ${lightGray}; border: 1px solid transparent; |