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;  | 
