diff options
author | Nate Sesti <33237525+sestinj@users.noreply.github.com> | 2023-09-23 13:06:00 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-23 13:06:00 -0700 |
commit | e976d60974a7837967d03807605cbf2e7b4f3f9a (patch) | |
tree | 5ecb19062abb162832530dd953e9d2801026c23c /extension/react-app/src | |
parent | 470711d25b44d1a545c57bc17d40d5e1fd402216 (diff) | |
download | sncontinue-e976d60974a7837967d03807605cbf2e7b4f3f9a.tar.gz sncontinue-e976d60974a7837967d03807605cbf2e7b4f3f9a.tar.bz2 sncontinue-e976d60974a7837967d03807605cbf2e7b4f3f9a.zip |
UI Redesign and fixing many details (#496)
* feat: :lipstick: start of major design upgrade
* feat: :lipstick: model selection page
* feat: :lipstick: use shortcut to add highlighted code as ctx
* feat: :lipstick: better display of errors
* feat: :lipstick: ui for learning keyboard shortcuts, more details
* refactor: :construction: testing slash commands ui
* Truncate continue.log
* refactor: :construction: refactoring client_session, ui, more
* feat: :bug: layout fixes
* refactor: :lipstick: ui to enter OpenAI Key
* refactor: :truck: rename MaybeProxyOpenAI -> OpenAIFreeTrial
* starting help center
* removing old shortcut docs
* fix: :bug: fix model setting logic to avoid overwrites
* feat: :lipstick: tutorial and model descriptions
* refactor: :truck: rename unused -> saved
* refactor: :truck: rename model roles
* feat: :lipstick: edit indicator
* refactor: :lipstick: move +, folder icons
* feat: :lipstick: tab to clear all context
* fix: :bug: context providers ui fixes
* fix: :bug: fix lag when stopping step
* fix: :bug: don't override system message for models
* fix: :bug: fix continue button cursor
* feat: :lipstick: title bar
* fix: :bug: updates to code highlighting logic and more
* fix: :bug: fix renaming of summarize model role
* feat: :lipstick: help page and better session title
* feat: :lipstick: more help page / ui improvements
* feat: :lipstick: set session title
* fix: :bug: small fixes for changing sessions
* fix: :bug: perfecting the highlighting code and ctx interactions
* style: :lipstick: sticky headers for scroll, ollama warming
* fix: :bug: fix toggle bug
---------
Co-authored-by: Ty Dunn <ty@tydunn.com>
Diffstat (limited to 'extension/react-app/src')
29 files changed, 2188 insertions, 1071 deletions
diff --git a/extension/react-app/src/App.tsx b/extension/react-app/src/App.tsx index edcac4a0..bbb1a952 100644 --- a/extension/react-app/src/App.tsx +++ b/extension/react-app/src/App.tsx @@ -1,5 +1,6 @@ import GUI from "./pages/gui"; import History from "./pages/history"; +import Help from "./pages/help"; import Layout from "./components/Layout"; import { createContext, useEffect } from "react"; import useContinueGUIProtocol from "./hooks/useWebsocket"; @@ -18,6 +19,8 @@ import { postVscMessage } from "./vscode"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; import ErrorPage from "./pages/error"; import SettingsPage from "./pages/settings"; +import Models from "./pages/models"; +import HelpPage from "./pages/help"; const router = createMemoryRouter([ { @@ -38,9 +41,21 @@ const router = createMemoryRouter([ element: <History />, }, { + path: "/help", + element: <Help />, + }, + { path: "/settings", element: <SettingsPage />, }, + { + path: "/models", + element: <Models />, + }, + { + path: "/help", + element: <HelpPage />, + }, ], }, ]); 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; diff --git a/extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts b/extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts index 9944f221..d71186d7 100644 --- a/extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts +++ b/extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts @@ -29,6 +29,8 @@ abstract class AbstractContinueGUIClientProtocol { abstract showLogsAtIndex(index: number): void; + abstract showContextVirtualFile(): void; + abstract selectContextItem(id: string, query: string): void; abstract loadSession(session_id?: string): void; @@ -52,6 +54,8 @@ abstract class AbstractContinueGUIClientProtocol { abstract selectContextGroup(id: string): void; abstract deleteContextGroup(id: string): void; + + abstract setCurrentSessionTitle(title: string): void; } export default AbstractContinueGUIClientProtocol; diff --git a/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts b/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts index fe1b654b..8205a629 100644 --- a/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts +++ b/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts @@ -23,12 +23,8 @@ class ContinueGUIClientProtocol extends AbstractContinueGUIClientProtocol { ? new VscodeMessenger(serverUrlWithSessionId) : new WebsocketMessenger(serverUrlWithSessionId); - this.messenger.onClose(() => { - console.log("GUI -> IDE websocket closed"); - }); - this.messenger.onError((error) => { - console.log("GUI -> IDE websocket error", error); - }); + this.messenger.onClose(() => {}); + this.messenger.onError((error) => {}); this.messenger.onMessageType("reconnect_at_session", (data: any) => { if (data.session_id) { @@ -52,6 +48,7 @@ class ContinueGUIClientProtocol extends AbstractContinueGUIClientProtocol { } onReconnectAtSession(session_id: string): void { + console.log("Reconnecting at session: ", session_id); this.connectMessenger( `${this.serverUrlWithSessionId.split("?")[0]}?session_id=${session_id}`, this.useVscodeMessagePassing @@ -122,6 +119,10 @@ class ContinueGUIClientProtocol extends AbstractContinueGUIClientProtocol { this.messenger?.send("show_logs_at_index", { index }); } + showContextVirtualFile(): void { + this.messenger?.send("show_context_virtual_file", {}); + } + selectContextItem(id: string, query: string): void { this.messenger?.send("select_context_item", { id, query }); } @@ -163,6 +164,10 @@ class ContinueGUIClientProtocol extends AbstractContinueGUIClientProtocol { deleteContextGroup(id: string): void { this.messenger?.send("delete_context_group", { id }); } + + setCurrentSessionTitle(title: string): void { + this.messenger?.send("set_current_session_title", { title }); + } } export default ContinueGUIClientProtocol; diff --git a/extension/react-app/src/index.css b/extension/react-app/src/index.css index 269da69a..3ecef025 100644 --- a/extension/react-app/src/index.css +++ b/extension/react-app/src/index.css @@ -11,7 +11,7 @@ --vscode-editor-background: rgb(30, 30, 30); --vscode-editor-foreground: rgb(197, 200, 198); - --vscode-textBlockQuote-background: rgba(255, 255, 255, 0.05); + --vscode-textBlockQuote-background: rgba(255, 255, 255, 1); } html, @@ -33,3 +33,7 @@ body { .press-start-2p { font-family: "Press Start 2P", "Lexend", sans-serif; } + +a:focus { + outline: none; +}
\ No newline at end of file diff --git a/extension/react-app/src/pages/gui.tsx b/extension/react-app/src/pages/gui.tsx index 9f58c505..78b7a970 100644 --- a/extension/react-app/src/pages/gui.tsx +++ b/extension/react-app/src/pages/gui.tsx @@ -1,7 +1,5 @@ import styled from "styled-components"; -import { defaultBorderRadius } from "../components"; -import Loader from "../components/Loader"; -import ContinueButton from "../components/ContinueButton"; +import { TextInput, defaultBorderRadius, lightGray } from "../components"; import { FullState } from "../../../schema/FullState"; import { useEffect, @@ -9,6 +7,7 @@ import { useState, useContext, useLayoutEffect, + useCallback, } from "react"; import { HistoryNode } from "../../../schema/HistoryNode"; import StepContainer from "../components/StepContainer"; @@ -32,6 +31,19 @@ import { setServerState, temporarilyPushToUserInputQueue, } from "../redux/slices/serverStateReducer"; +import TimelineItem from "../components/TimelineItem"; +import ErrorStepContainer from "../components/ErrorStepContainer"; +import { + ChatBubbleOvalLeftIcon, + CodeBracketSquareIcon, + ExclamationTriangleIcon, + FolderIcon, + PlusIcon, +} from "@heroicons/react/24/outline"; +import FTCDialog from "../components/dialogs/FTCDialog"; +import HeaderButtonWithText from "../components/HeaderButtonWithText"; +import { useNavigate } from "react-router-dom"; +import SuggestionsArea from "../components/Suggestions"; const TopGuiDiv = styled.div` overflow-y: scroll; @@ -44,6 +56,44 @@ const TopGuiDiv = styled.div` } `; +const TitleTextInput = styled(TextInput)` + border: none; + outline: none; + + font-size: 16px; + font-weight: bold; + margin: 0; + margin-right: 8px; + padding-top: 6px; + padding-bottom: 6px; + + &:focus { + outline: 1px solid ${lightGray}; + } +`; + +const StepsDiv = styled.div` + position: relative; + background-color: transparent; + padding-left: 8px; + padding-right: 8px; + + & > * { + z-index: 1; + position: relative; + } + + &::before { + content: ""; + position: absolute; + height: calc(100% - 24px); + border-left: 2px solid ${lightGray}; + left: 28px; + z-index: 0; + bottom: 24px; + } +`; + const UserInputQueueItem = styled.div` border-radius: ${defaultBorderRadius}; color: gray; @@ -52,6 +102,16 @@ const UserInputQueueItem = styled.div` text-align: center; `; +const GUIHeaderDiv = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px; + padding-left: 8px; + padding-right: 8px; + border-bottom: 0.5px solid ${lightGray}; +`; + interface GUIProps { firstObservation?: any; } @@ -61,6 +121,7 @@ function GUI(props: GUIProps) { const client = useContext(GUIClientContext); const posthog = usePostHog(); const dispatch = useDispatch(); + const navigate = useNavigate(); // #endregion @@ -73,26 +134,16 @@ function GUI(props: GUIProps) { const user_input_queue = useSelector( (state: RootStore) => state.serverState.user_input_queue ); - const adding_highlighted_code = useSelector( - (state: RootStore) => state.serverState.adding_highlighted_code - ); - const selected_context_items = useSelector( - (state: RootStore) => state.serverState.selected_context_items + + const sessionTitle = useSelector( + (state: RootStore) => state.serverState.session_info?.title ); // #endregion // #region State const [waitingForSteps, setWaitingForSteps] = useState(false); - const [availableSlashCommands, setAvailableSlashCommands] = useState< - { name: string; description: string }[] - >([]); - const [stepsOpen, setStepsOpen] = useState<boolean[]>([ - true, - true, - true, - true, - ]); + const [stepsOpen, setStepsOpen] = useState<(boolean | undefined)[]>([]); const [waitingForClient, setWaitingForClient] = useState(true); const [showLoading, setShowLoading] = useState(false); @@ -150,7 +201,7 @@ function GUI(props: GUIProps) { topGuiDivRef.current?.scrollTo({ top: topGuiDivRef.current?.scrollHeight, - behavior: "smooth" as any, + behavior: "instant" as any, }); }, [topGuiDivRef.current?.scrollHeight, history.timeline]); @@ -160,6 +211,7 @@ function GUI(props: GUIProps) { if ( e.key === "Backspace" && isMetaEquivalentKeyPressed(e) && + !e.shiftKey && typeof history?.current_index !== "undefined" && history.timeline[history.current_index]?.active ) { @@ -188,14 +240,6 @@ function GUI(props: GUIProps) { dispatch(setServerState(state)); setWaitingForSteps(waitingForSteps); - setAvailableSlashCommands( - state.slash_commands.map((c: any) => { - return { - name: `/${c.name}`, - description: c.description, - }; - }) - ); setStepsOpen((prev) => { const nextStepsOpen = [...prev]; for ( @@ -203,7 +247,7 @@ function GUI(props: GUIProps) { i < state.history.timeline.length; i++ ) { - nextStepsOpen.push(true); + nextStepsOpen.push(undefined); } return nextStepsOpen; }); @@ -214,7 +258,6 @@ function GUI(props: GUIProps) { useEffect(() => { if (client && waitingForClient) { - console.log("sending user input queue, ", user_input_queue); setWaitingForClient(false); for (const input of user_input_queue) { client.sendMainInput(input); @@ -244,43 +287,22 @@ function GUI(props: GUIProps) { return; } - // Increment localstorage counter for usage of free trial if ( - defaultModel === "MaybeProxyOpenAI" && + defaultModel === "OpenAIFreeTrial" && (!input.startsWith("/") || input.startsWith("/edit")) ) { - const freeTrialCounter = localStorage.getItem("freeTrialCounter"); - if (freeTrialCounter) { - const usages = parseInt(freeTrialCounter); - localStorage.setItem("freeTrialCounter", (usages + 1).toString()); + const ftc = localStorage.getItem("ftc"); + if (ftc) { + const u = parseInt(ftc); + localStorage.setItem("ftc", (u + 1).toString()); - if (usages >= 250) { - console.log("Free trial limit reached"); + if (u >= 250) { dispatch(setShowDialog(true)); - dispatch( - setDialogMessage( - <div className="p-4"> - <h3>Free Trial Limit Reached</h3> - 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 MaybeProxyOpenAI object. - </div> - ) - ); + dispatch(setDialogMessage(<FTCDialog />)); return; } } else { - localStorage.setItem("freeTrialCounter", "1"); + localStorage.setItem("ftc", "1"); } } @@ -391,6 +413,69 @@ function GUI(props: GUIProps) { client.sendStepUserInput(input, index); }; + const getStepsInUserInputGroup = useCallback( + (index: number): number[] => { + // index is the index in the entire timeline, hidden steps included + const stepsInUserInputGroup: number[] = []; + + // First find the closest above UserInputStep + let userInputIndex = -1; + for (let i = index; i >= 0; i--) { + if ( + history?.timeline.length > i && + history.timeline[i].step.name === "User Input" && + history.timeline[i].step.hide === false + ) { + stepsInUserInputGroup.push(i); + userInputIndex = i; + break; + } + } + if (stepsInUserInputGroup.length === 0) return []; + + for (let i = userInputIndex + 1; i < history?.timeline.length; i++) { + if ( + history?.timeline.length > i && + history.timeline[i].step.name === "User Input" && + history.timeline[i].step.hide === false + ) { + break; + } + stepsInUserInputGroup.push(i); + } + return stepsInUserInputGroup; + }, + [history.timeline] + ); + + const onToggleAtIndex = useCallback( + (index: number) => { + // Check if all steps after the User Input are closed + const groupIndices = getStepsInUserInputGroup(index); + const userInputIndex = groupIndices[0]; + setStepsOpen((prev) => { + const nextStepsOpen = [...prev]; + nextStepsOpen[index] = !nextStepsOpen[index]; + const allStepsAfterUserInputAreClosed = !groupIndices.some( + (i, j) => j > 0 && nextStepsOpen[i] + ); + if (allStepsAfterUserInputAreClosed) { + nextStepsOpen[userInputIndex] = false; + } else { + const allStepsAfterUserInputAreOpen = !groupIndices.some( + (i, j) => j > 0 && !nextStepsOpen[i] + ); + if (allStepsAfterUserInputAreOpen) { + nextStepsOpen[userInputIndex] = true; + } + } + + return nextStepsOpen; + }); + }, + [getStepsInUserInputGroup] + ); + useEffect(() => { const timeout = setTimeout(() => { setShowLoading(true); @@ -400,6 +485,17 @@ function GUI(props: GUIProps) { clearTimeout(timeout); }; }, []); + + useEffect(() => { + if (sessionTitle) { + setSessionTitleInput(sessionTitle); + } + }, [sessionTitle]); + + const [sessionTitleInput, setSessionTitleInput] = useState<string>( + sessionTitle || "New Session" + ); + return ( <TopGuiDiv ref={topGuiDivRef} @@ -409,6 +505,51 @@ function GUI(props: GUIProps) { } }} > + <GUIHeaderDiv> + <TitleTextInput + onClick={(e) => { + // Select all text + (e.target as any).setSelectionRange( + 0, + (e.target as any).value.length + ); + }} + value={sessionTitleInput} + onChange={(e) => setSessionTitleInput(e.target.value)} + onBlur={(e) => { + client?.setCurrentSessionTitle(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + (e.target as any).blur(); + } + }} + /> + <div className="flex"> + {history.timeline.filter((n) => !n.step.hide).length > 0 && ( + <HeaderButtonWithText + onClick={() => { + if (history.timeline.filter((n) => !n.step.hide).length > 0) { + client?.loadSession(undefined); + } + }} + text="New Session (⌥⌘N)" + > + <PlusIcon width="1.4em" height="1.4em" /> + </HeaderButtonWithText> + )} + + <HeaderButtonWithText + onClick={() => { + navigate("/history"); + }} + text="History" + > + <FolderIcon width="1.4em" height="1.4em" /> + </HeaderButtonWithText> + </div> + </GUIHeaderDiv> {showLoading && typeof client === "undefined" && ( <> <RingLoader /> @@ -478,63 +619,128 @@ function GUI(props: GUIProps) { </u> </p> </div> - - <div className="w-3/4 m-auto text-center text-xs"> - {/* Tip: Drag the Continue logo from the far left of the window to the - right, then toggle Continue using option/alt+command+m. */} - {/* Tip: If there is an error in the terminal, use COMMAND+D to - automatically debug */} - </div> </> )} - {history?.timeline.map((node: HistoryNode, index: number) => { - return node.step.name === "User Input" ? ( - node.step.hide || ( - <UserInputContainer - index={index} - onDelete={() => { - client?.deleteAtIndex(index); - }} - historyNode={node} - > - {node.step.description as string} - </UserInputContainer> - ) - ) : ( - <StepContainer - index={index} - isLast={index === history.timeline.length - 1} - isFirst={index === 0} - open={stepsOpen[index]} - onToggle={() => { - const nextStepsOpen = [...stepsOpen]; - nextStepsOpen[index] = !nextStepsOpen[index]; - setStepsOpen(nextStepsOpen); - }} - onToggleAll={() => { - const shouldOpen = !stepsOpen[index]; - setStepsOpen((prev) => prev.map(() => shouldOpen)); - }} - key={index} - onUserInput={(input: string) => { - onStepUserInput(input, index); - }} - inFuture={index > history?.current_index} - historyNode={node} - onReverse={() => { - client?.reverseToIndex(index); - }} - onRetry={() => { - client?.retryAtIndex(index); - setWaitingForSteps(true); - }} - onDelete={() => { - client?.deleteAtIndex(index); - }} - /> - ); - })} - {waitingForSteps && <Loader />} + <br /> + <SuggestionsArea + onClick={(textInput) => { + client?.sendMainInput(textInput); + }} + /> + <StepsDiv> + {history?.timeline.map((node: HistoryNode, index: number) => { + if (node.step.hide) return null; + return ( + <> + {node.step.name === "User Input" ? ( + node.step.hide || ( + <UserInputContainer + active={getStepsInUserInputGroup(index).some((i) => { + return history.timeline[i].active; + })} + groupIndices={getStepsInUserInputGroup(index)} + onToggle={(isOpen: boolean) => { + // Collapse all steps in the section + setStepsOpen((prev) => { + const nextStepsOpen = [...prev]; + getStepsInUserInputGroup(index).forEach((i) => { + nextStepsOpen[i] = isOpen; + }); + return nextStepsOpen; + }); + }} + onToggleAll={(isOpen: boolean) => { + // Collapse _all_ steps + setStepsOpen((prev) => { + return prev.map((_) => isOpen); + }); + }} + isToggleOpen={ + typeof stepsOpen[index] === "undefined" + ? true + : stepsOpen[index]! + } + index={index} + onDelete={() => { + // Delete the input and all steps until the next user input + getStepsInUserInputGroup(index).forEach((i) => { + client?.deleteAtIndex(i); + }); + }} + historyNode={node} + > + {node.step.description as string} + </UserInputContainer> + ) + ) : ( + <TimelineItem + historyNode={node} + iconElement={ + node.step.class_name === "DefaultModelEditCodeStep" ? ( + <CodeBracketSquareIcon width="16px" height="16px" /> + ) : node.observation?.error ? ( + <ExclamationTriangleIcon + width="16px" + height="16px" + color="red" + /> + ) : ( + <ChatBubbleOvalLeftIcon width="16px" height="16px" /> + ) + } + open={ + typeof stepsOpen[index] === "undefined" + ? node.observation?.error + ? false + : true + : stepsOpen[index]! + } + onToggle={() => onToggleAtIndex(index)} + > + {node.observation?.error ? ( + <ErrorStepContainer + onClose={() => onToggleAtIndex(index)} + historyNode={node} + onDelete={() => client?.deleteAtIndex(index)} + /> + ) : ( + <StepContainer + index={index} + isLast={index === history.timeline.length - 1} + isFirst={index === 0} + open={ + typeof stepsOpen[index] === "undefined" + ? true + : stepsOpen[index]! + } + key={index} + onUserInput={(input: string) => { + onStepUserInput(input, index); + }} + inFuture={index > history?.current_index} + historyNode={node} + onReverse={() => { + client?.reverseToIndex(index); + }} + onRetry={() => { + client?.retryAtIndex(index); + setWaitingForSteps(true); + }} + onDelete={() => { + client?.deleteAtIndex(index); + }} + noUserInputParent={ + getStepsInUserInputGroup(index).length === 0 + } + /> + )} + </TimelineItem> + )} + {/* <div className="h-2"></div> */} + </> + ); + })} + </StepsDiv> <div> {user_input_queue?.map?.((input) => { @@ -547,18 +753,14 @@ function GUI(props: GUIProps) { ref={mainTextInputRef} onEnter={(e) => { onMainTextInput(e); - e.stopPropagation(); - e.preventDefault(); + e?.stopPropagation(); + e?.preventDefault(); }} onInputValueChange={() => {}} - items={availableSlashCommands} - selectedContextItems={selected_context_items} onToggleAddContext={() => { client?.toggleAddingHighlightedCode(); }} - addingHighlightedCode={adding_highlighted_code} /> - <ContinueButton onClick={onMainTextInput} /> </TopGuiDiv> ); } diff --git a/extension/react-app/src/pages/help.tsx b/extension/react-app/src/pages/help.tsx new file mode 100644 index 00000000..3e2e93d2 --- /dev/null +++ b/extension/react-app/src/pages/help.tsx @@ -0,0 +1,98 @@ +import { useNavigate } from "react-router-dom"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import KeyboardShortcutsDialog from "../components/dialogs/KeyboardShortcuts"; +import { buttonColor, lightGray, vscBackground } from "../components"; +import styled from "styled-components"; + +const IconDiv = styled.div<{ backgroundColor?: string }>` + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + height: 100%; + padding: 0 4px; + + &:hover { + background-color: ${(props) => props.backgroundColor || lightGray}; + } +`; + +function HelpPage() { + const navigate = useNavigate(); + + return ( + <div className="overflow-scroll"> + <div + className="items-center flex m-0 p-0 sticky top-0" + style={{ + borderBottom: `0.5px solid ${lightGray}`, + backgroundColor: vscBackground, + }} + > + <ArrowLeftIcon + width="1.2em" + height="1.2em" + onClick={() => navigate("/")} + className="inline-block ml-4 cursor-pointer" + /> + <h3 className="text-lg font-bold m-2 inline-block">Help Center</h3> + </div> + + <div className="grid grid-cols-2 grid-rows-2"> + <IconDiv backgroundColor="rgb(234, 51, 35)"> + <a href="https://youtu.be/3Ocrc-WX4iQ?si=eDLYtkc6CXQoHsEc"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="-5.2 -4.5 60 60" + fill="white" + className="w-full h-full" + > + <path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"></path> + </svg> + </a> + </IconDiv> + <IconDiv backgroundColor={buttonColor}> + <a href="https://continue.dev/docs/how-to-use-continue"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="-2.2 -2 28 28" + fill="white" + className="w-full h-full flex items-center justify-center" + > + <path d="M11.25 4.533A9.707 9.707 0 006 3a9.735 9.735 0 00-3.25.555.75.75 0 00-.5.707v14.25a.75.75 0 001 .707A8.237 8.237 0 016 18.75c1.995 0 3.823.707 5.25 1.886V4.533zM12.75 20.636A8.214 8.214 0 0118 18.75c.966 0 1.89.166 2.75.47a.75.75 0 001-.708V4.262a.75.75 0 00-.5-.707A9.735 9.735 0 0018 3a9.707 9.707 0 00-5.25 1.533v16.103z" /> + </svg> + </a> + </IconDiv> + <IconDiv backgroundColor="rgb(88, 98, 227)"> + <a href="https://discord.gg/vapESyrFmJ"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="-5 -5.5 60 60" + fill="white" + className="w-full h-full" + > + <path d="M 41.625 10.769531 C 37.644531 7.566406 31.347656 7.023438 31.078125 7.003906 C 30.660156 6.96875 30.261719 7.203125 30.089844 7.589844 C 30.074219 7.613281 29.9375 7.929688 29.785156 8.421875 C 32.417969 8.867188 35.652344 9.761719 38.578125 11.578125 C 39.046875 11.867188 39.191406 12.484375 38.902344 12.953125 C 38.710938 13.261719 38.386719 13.429688 38.050781 13.429688 C 37.871094 13.429688 37.6875 13.378906 37.523438 13.277344 C 32.492188 10.15625 26.210938 10 25 10 C 23.789063 10 17.503906 10.15625 12.476563 13.277344 C 12.007813 13.570313 11.390625 13.425781 11.101563 12.957031 C 10.808594 12.484375 10.953125 11.871094 11.421875 11.578125 C 14.347656 9.765625 17.582031 8.867188 20.214844 8.425781 C 20.0625 7.929688 19.925781 7.617188 19.914063 7.589844 C 19.738281 7.203125 19.34375 6.960938 18.921875 7.003906 C 18.652344 7.023438 12.355469 7.566406 8.320313 10.8125 C 6.214844 12.761719 2 24.152344 2 34 C 2 34.175781 2.046875 34.34375 2.132813 34.496094 C 5.039063 39.605469 12.972656 40.941406 14.78125 41 C 14.789063 41 14.800781 41 14.8125 41 C 15.132813 41 15.433594 40.847656 15.621094 40.589844 L 17.449219 38.074219 C 12.515625 36.800781 9.996094 34.636719 9.851563 34.507813 C 9.4375 34.144531 9.398438 33.511719 9.765625 33.097656 C 10.128906 32.683594 10.761719 32.644531 11.175781 33.007813 C 11.234375 33.0625 15.875 37 25 37 C 34.140625 37 38.78125 33.046875 38.828125 33.007813 C 39.242188 32.648438 39.871094 32.683594 40.238281 33.101563 C 40.601563 33.515625 40.5625 34.144531 40.148438 34.507813 C 40.003906 34.636719 37.484375 36.800781 32.550781 38.074219 L 34.378906 40.589844 C 34.566406 40.847656 34.867188 41 35.1875 41 C 35.199219 41 35.210938 41 35.21875 41 C 37.027344 40.941406 44.960938 39.605469 47.867188 34.496094 C 47.953125 34.34375 48 34.175781 48 34 C 48 24.152344 43.785156 12.761719 41.625 10.769531 Z M 18.5 30 C 16.566406 30 15 28.210938 15 26 C 15 23.789063 16.566406 22 18.5 22 C 20.433594 22 22 23.789063 22 26 C 22 28.210938 20.433594 30 18.5 30 Z M 31.5 30 C 29.566406 30 28 28.210938 28 26 C 28 23.789063 29.566406 22 31.5 22 C 33.433594 22 35 23.789063 35 26 C 35 28.210938 33.433594 30 31.5 30 Z"></path> + </svg> + </a> + </IconDiv> + <IconDiv> + <a href="https://github.com/continuedev/continue/issues/new/choose"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="-1.2 -1.2 32 32" + fill="white" + className="w-full h-full" + > + <path d="M15,3C8.373,3,3,8.373,3,15c0,5.623,3.872,10.328,9.092,11.63C12.036,26.468,12,26.28,12,26.047v-2.051 c-0.487,0-1.303,0-1.508,0c-0.821,0-1.551-0.353-1.905-1.009c-0.393-0.729-0.461-1.844-1.435-2.526 c-0.289-0.227-0.069-0.486,0.264-0.451c0.615,0.174,1.125,0.596,1.605,1.222c0.478,0.627,0.703,0.769,1.596,0.769 c0.433,0,1.081-0.025,1.691-0.121c0.328-0.833,0.895-1.6,1.588-1.962c-3.996-0.411-5.903-2.399-5.903-5.098 c0-1.162,0.495-2.286,1.336-3.233C9.053,10.647,8.706,8.73,9.435,8c1.798,0,2.885,1.166,3.146,1.481C13.477,9.174,14.461,9,15.495,9 c1.036,0,2.024,0.174,2.922,0.483C18.675,9.17,19.763,8,21.565,8c0.732,0.731,0.381,2.656,0.102,3.594 c0.836,0.945,1.328,2.066,1.328,3.226c0,2.697-1.904,4.684-5.894,5.097C18.199,20.49,19,22.1,19,23.313v2.734 c0,0.104-0.023,0.179-0.035,0.268C23.641,24.676,27,20.236,27,15C27,8.373,21.627,3,15,3z"></path> + </svg> + </a> + </IconDiv> + </div> + + <KeyboardShortcutsDialog></KeyboardShortcutsDialog> + </div> + ); +} + +export default HelpPage; diff --git a/extension/react-app/src/pages/history.tsx b/extension/react-app/src/pages/history.tsx index b901dd55..b6de0520 100644 --- a/extension/react-app/src/pages/history.tsx +++ b/extension/react-app/src/pages/history.tsx @@ -1,13 +1,14 @@ import React, { useContext, useEffect, useState } from "react"; import { SessionInfo } from "../../../schema/SessionInfo"; import { GUIClientContext } from "../App"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { RootStore } from "../redux/store"; import { useNavigate } from "react-router-dom"; -import { secondaryDark, vscBackground } from "../components"; +import { lightGray, secondaryDark, vscBackground } from "../components"; import styled from "styled-components"; import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import CheckDiv from "../components/CheckDiv"; +import { temporarilyClearSession } from "../redux/slices/serverStateReducer"; const Tr = styled.tr` &:hover { @@ -41,6 +42,7 @@ function lastPartOfPath(path: string): string { function History() { const navigate = useNavigate(); + const dispatch = useDispatch(); const [sessions, setSessions] = useState<SessionInfo[]>([]); const client = useContext(GUIClientContext); const apiUrl = useSelector((state: RootStore) => state.config.apiUrl); @@ -67,78 +69,106 @@ function History() { fetchSessions(); }, [client]); - console.log(sessions.map((session) => session.date_created)); - return ( - <div className="w-full"> - <div className="items-center flex"> - <ArrowLeftIcon - width="1.4em" - height="1.4em" - onClick={() => navigate("/")} - className="inline-block ml-4 cursor-pointer" - /> - <h1 className="text-xl font-bold m-4 inline-block">History</h1> + <div className="overflow-y-scroll"> + <div className="sticky top-0" style={{ backgroundColor: vscBackground }}> + <div + className="items-center flex m-0 p-0" + style={{ + borderBottom: `0.5px solid ${lightGray}`, + }} + > + <ArrowLeftIcon + width="1.2em" + height="1.2em" + onClick={() => navigate("/")} + className="inline-block ml-4 cursor-pointer" + /> + <h3 className="text-lg font-bold m-2 inline-block">History</h3> + </div> + {workspacePaths && workspacePaths.length > 0 && ( + <CheckDiv + checked={filteringByWorkspace} + onClick={() => setFilteringByWorkspace((prev) => !prev)} + title={`Show only sessions from ${lastPartOfPath( + workspacePaths[workspacePaths.length - 1] + )}/`} + /> + )} </div> - {workspacePaths && workspacePaths.length > 0 && ( - <CheckDiv - checked={filteringByWorkspace} - onClick={() => setFilteringByWorkspace((prev) => !prev)} - title={`Show only sessions from ${lastPartOfPath( - workspacePaths[workspacePaths.length - 1] - )}/`} - /> + + {sessions.filter((session) => { + if ( + !filteringByWorkspace || + typeof workspacePaths === "undefined" || + typeof session.workspace_directory === "undefined" + ) { + return true; + } + return workspacePaths.includes(session.workspace_directory); + }).length === 0 && ( + <div className="text-center my-4"> + No past sessions found. To start a new session, either click the "+" + button or use the keyboard shortcut: <b>Option + Command + N</b> + </div> )} - <table className="w-full"> - <tbody> - {sessions - .filter((session) => { - if ( - !filteringByWorkspace || - typeof workspacePaths === "undefined" || - typeof session.workspace_directory === "undefined" - ) { - return true; - } - return workspacePaths.includes(session.workspace_directory); - }) - .sort( - (a, b) => - parseDate(b.date_created).getTime() - - parseDate(a.date_created).getTime() - ) - .map((session, index) => ( - <Tr key={index}> - <td> - <TdDiv - onClick={() => { - client?.loadSession(session.session_id); - navigate("/"); - }} - > - <div className="text-md">{session.title}</div> - <div className="text-gray-400"> - {parseDate(session.date_created).toLocaleString("en-US", { - weekday: "short", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "numeric", - })} - {" | "} - {lastPartOfPath(session.workspace_directory || "")}/ - </div> - </TdDiv> - </td> - </Tr> - ))} - </tbody> - </table> - <br /> - <i className="text-sm ml-4"> - All session data is saved in ~/.continue/sessions - </i> + + <div> + <table className="w-full"> + <tbody> + {sessions + .filter((session) => { + if ( + !filteringByWorkspace || + typeof workspacePaths === "undefined" || + typeof session.workspace_directory === "undefined" + ) { + return true; + } + return workspacePaths.includes(session.workspace_directory); + }) + .sort( + (a, b) => + parseDate(b.date_created).getTime() - + parseDate(a.date_created).getTime() + ) + .map((session, index) => ( + <Tr key={index}> + <td> + <TdDiv + onClick={() => { + client?.loadSession(session.session_id); + dispatch(temporarilyClearSession()); + navigate("/"); + }} + > + <div className="text-md">{session.title}</div> + <div className="text-gray-400"> + {parseDate(session.date_created).toLocaleString( + "en-US", + { + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour: "numeric", + minute: "2-digit", + hour12: true, + } + )} + {" | "} + {lastPartOfPath(session.workspace_directory || "")}/ + </div> + </TdDiv> + </td> + </Tr> + ))} + </tbody> + </table> + <br /> + <i className="text-sm ml-4"> + All session data is saved in ~/.continue/sessions + </i> + </div> </div> ); } diff --git a/extension/react-app/src/pages/models.tsx b/extension/react-app/src/pages/models.tsx new file mode 100644 index 00000000..1a6f275b --- /dev/null +++ b/extension/react-app/src/pages/models.tsx @@ -0,0 +1,167 @@ +import React from "react"; +import ModelCard, { ModelInfo, ModelTag } from "../components/ModelCard"; +import styled from "styled-components"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { lightGray, vscBackground } from "../components"; +import { useNavigate } from "react-router-dom"; + +const MODEL_INFO: ModelInfo[] = [ + { + title: "OpenAI", + class: "OpenAI", + description: "Use gpt-4, gpt-3.5-turbo, or any other OpenAI model", + args: { + model: "gpt-4", + api_key: "", + title: "OpenAI", + }, + icon: "openai.svg", + tags: [ModelTag["Requires API Key"]], + }, + { + title: "Anthropic", + class: "AnthropicLLM", + description: + "Claude-2 is a highly capable model with a 100k context length", + args: { + model: "claude-2", + api_key: "<ANTHROPIC_API_KEY>", + title: "Anthropic", + }, + icon: "anthropic.png", + tags: [ModelTag["Requires API Key"]], + }, + { + title: "Ollama", + class: "Ollama", + description: + "One of the fastest ways to get started with local models on Mac", + args: { + model: "codellama", + title: "Ollama", + }, + icon: "ollama.png", + tags: [ModelTag["Local"], ModelTag["Open-Source"]], + }, + { + title: "TogetherAI", + class: "TogetherLLM", + description: + "Use the TogetherAI API for extremely fast streaming of open-source models", + args: { + model: "togethercomputer/CodeLlama-13b-Instruct", + api_key: "<TOGETHER_API_KEY>", + title: "TogetherAI", + }, + icon: "together.png", + tags: [ModelTag["Requires API Key"], ModelTag["Open-Source"]], + }, + { + title: "LM Studio", + class: "GGML", + description: + "One of the fastest ways to get started with local models on Mac or Windows", + args: { + server_url: "http://localhost:1234", + title: "LM Studio", + }, + icon: "lmstudio.png", + tags: [ModelTag["Local"], ModelTag["Open-Source"]], + }, + { + title: "Replicate", + class: "ReplicateLLM", + description: "Use the Replicate API to run open-source models", + args: { + model: + "replicate/llama-2-70b-chat:58d078176e02c219e11eb4da5a02a7830a283b14cf8f94537af893ccff5ee781", + api_key: "<REPLICATE_API_KEY>", + title: "Replicate", + }, + icon: "replicate.png", + tags: [ModelTag["Requires API Key"], ModelTag["Open-Source"]], + }, + { + title: "llama.cpp", + class: "LlamaCpp", + description: "If you are running the llama.cpp server from source", + args: { + title: "llama.cpp", + }, + icon: "llamacpp.png", + tags: [ModelTag.Local, ModelTag["Open-Source"]], + }, + { + title: "HuggingFace TGI", + class: "HuggingFaceTGI", + description: + "HuggingFace Text Generation Inference is an advanced, highly performant option for serving open-source models to multiple people", + args: { + title: "HuggingFace TGI", + }, + icon: "hf.png", + tags: [ModelTag.Local, ModelTag["Open-Source"]], + }, + { + title: "Other OpenAI-compatible API", + class: "GGML", + description: + "If you are using any other OpenAI-compatible API, for example text-gen-webui, FastChat, LocalAI, or llama-cpp-python, you can simply enter your server URL", + args: { + server_url: "<SERVER_URL>", + }, + icon: "openai.svg", + tags: [ModelTag.Local, ModelTag["Open-Source"]], + }, + { + title: "GPT-4 limited free trial", + class: "OpenAIFreeTrial", + description: + "New users can try out Continue with GPT-4 using a proxy server that securely makes calls to OpenAI using our API key", + args: { + model: "gpt-4", + title: "GPT-4 Free Trial", + }, + icon: "openai.svg", + tags: [ModelTag.Free], + }, +]; + +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; +`; + +function Models() { + const navigate = useNavigate(); + return ( + <div className="overflow-y-scroll"> + <div + className="items-center flex m-0 p-0 sticky top-0" + style={{ + borderBottom: `0.5px solid ${lightGray}`, + backgroundColor: vscBackground, + }} + > + <ArrowLeftIcon + width="1.2em" + height="1.2em" + onClick={() => navigate("/")} + className="inline-block ml-4 cursor-pointer" + /> + <h3 className="text-lg font-bold m-2 inline-block">Add a new model</h3> + </div> + <GridDiv> + {MODEL_INFO.map((model) => ( + <ModelCard modelInfo={model} /> + ))} + </GridDiv> + </div> + ); +} + +export default Models; diff --git a/extension/react-app/src/pages/settings.tsx b/extension/react-app/src/pages/settings.tsx index 8b3d9c5b..4bd51163 100644 --- a/extension/react-app/src/pages/settings.tsx +++ b/extension/react-app/src/pages/settings.tsx @@ -1,15 +1,23 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useContext } from "react"; import { GUIClientContext } from "../App"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { RootStore } from "../redux/store"; import { useNavigate } from "react-router-dom"; import { ContinueConfig } from "../../../schema/ContinueConfig"; -import { Button, TextArea, lightGray, secondaryDark } from "../components"; +import { + Button, + TextArea, + lightGray, + secondaryDark, + vscBackground, +} from "../components"; import styled from "styled-components"; -import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { ArrowLeftIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; import Loader from "../components/Loader"; import InfoHover from "../components/InfoHover"; import { FormProvider, useForm } from "react-hook-form"; +import { setDialogMessage, setShowDialog } from "../redux/slices/uiStateSlice"; +import KeyboardShortcutsDialog from "../components/dialogs/KeyboardShortcuts"; const Hr = styled.hr` border: 0.5px solid ${lightGray}; @@ -70,7 +78,7 @@ const Slider = styled.input.attrs({ type: "range" })` border: none; } `; -const ALL_MODEL_ROLES = ["default", "small", "medium", "large", "edit", "chat"]; +const ALL_MODEL_ROLES = ["default", "summarize", "edit", "chat"]; function Settings() { const formMethods = useForm<ContinueConfig>(); @@ -79,6 +87,7 @@ function Settings() { const navigate = useNavigate(); const client = useContext(GUIClientContext); const config = useSelector((state: RootStore) => state.serverState.config); + const dispatch = useDispatch(); const submitChanges = () => { if (!client) return; @@ -106,17 +115,23 @@ function Settings() { return ( <FormProvider {...formMethods}> - <div className="w-full"> + <div className="overflow-scroll"> + <div + className="items-center flex sticky top-0" + style={{ + borderBottom: `0.5px solid ${lightGray}`, + backgroundColor: vscBackground, + }} + > + <ArrowLeftIcon + width="1.2em" + height="1.2em" + onClick={submitAndLeave} + className="inline-block ml-4 cursor-pointer" + /> + <h3 className="text-lg font-bold m-2 inline-block">Settings</h3> + </div> <form onSubmit={formMethods.handleSubmit(onSubmit)}> - <div className="items-center flex"> - <ArrowLeftIcon - width="1.4em" - height="1.4em" - onClick={submitAndLeave} - className="inline-block ml-4 cursor-pointer" - /> - <h1 className="text-2xl font-bold m-4 inline-block">Settings</h1> - </div> {config ? ( <div className="p-2"> <h3 className="flex gap-1"> diff --git a/extension/react-app/src/redux/slices/serverStateReducer.ts b/extension/react-app/src/redux/slices/serverStateReducer.ts index 904b0e76..3a2e455a 100644 --- a/extension/react-app/src/redux/slices/serverStateReducer.ts +++ b/extension/react-app/src/redux/slices/serverStateReducer.ts @@ -1,6 +1,74 @@ import { createSlice } from "@reduxjs/toolkit"; import { FullState } from "../../../../schema/FullState"; +const TEST_TIMELINE = [ + { + step: { + description: "Hi, please write bubble sort in python", + name: "User Input", + }, + }, + { + step: { + description: `\`\`\`python +def bubble_sort(arr): + n = len(arr) + for i in range(n): + for j in range(0, n - i - 1): + if arr[j] > arr[j + 1]: + arr[j], arr[j + 1] = arr[j + 1], arr[j] + return arr +\`\`\``, + name: "Bubble Sort in Python", + }, + }, + { + step: { + description: "Now write it in Rust", + name: "User Input", + }, + }, + { + step: { + description: "Hello! This is a test...\n\n1, 2, 3, testing...", + name: "Testing", + }, + }, + { + step: { + description: `Sure, here's bubble sort written in rust: \n\`\`\`rust +fn bubble_sort<T: Ord>(values: &mut[T]) { + let len = values.len(); + for i in 0..len { + for j in 0..(len - i - 1) { + if values[j] > values[j + 1] { + values.swap(j, j + 1); + } + } + } +} +\`\`\`\nIs there anything else I can answer?`, + name: "Rust Bubble Sort", + }, + active: true, + }, +]; + +const TEST_SLASH_COMMANDS = [ + { + name: "edit", + description: "Edit the code", + }, + { + name: "cmd", + description: "Generate a command", + }, + { + name: "help", + description: "Get help using Continue", + }, +]; + const initialState: FullState = { history: { timeline: [], @@ -30,9 +98,21 @@ export const serverStateSlice = createSlice({ temporarilyPushToUserInputQueue: (state, action) => { state.user_input_queue = [...state.user_input_queue, action.payload]; }, + temporarilyClearSession: (state) => { + state.history.timeline = []; + state.selected_context_items = []; + state.session_info = { + title: "Loading session...", + session_id: "", + date_created: "", + }; + }, }, }); -export const { setServerState, temporarilyPushToUserInputQueue } = - serverStateSlice.actions; +export const { + setServerState, + temporarilyPushToUserInputQueue, + temporarilyClearSession, +} = serverStateSlice.actions; export default serverStateSlice.reducer; |