diff options
Diffstat (limited to 'extension/react-app/src')
40 files changed, 2682 insertions, 0 deletions
diff --git a/extension/react-app/src/App.tsx b/extension/react-app/src/App.tsx new file mode 100644 index 00000000..0c40ced1 --- /dev/null +++ b/extension/react-app/src/App.tsx @@ -0,0 +1,29 @@ +import DebugPanel from "./components/DebugPanel"; +import MainTab from "./tabs/main"; +import { Provider } from "react-redux"; +import store from "./redux/store"; +import WelcomeTab from "./tabs/welcome"; +import ChatTab from "./tabs/chat"; +import Notebook from "./tabs/notebook"; + +function App() { + return ( + <> + <Provider store={store}> + <DebugPanel + tabs={[ + { + element: <Notebook />, + title: "Notebook", + }, + // { element: <MainTab />, title: "Debug Panel" }, + // { element: <WelcomeTab />, title: "Welcome" }, + // { element: <ChatTab />, title: "Chat" }, + ]} + ></DebugPanel> + </Provider> + </> + ); +} + +export default App; diff --git a/extension/react-app/src/TestPage.tsx b/extension/react-app/src/TestPage.tsx new file mode 100644 index 00000000..d104980b --- /dev/null +++ b/extension/react-app/src/TestPage.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import styled from "styled-components"; + +const SideBySideDiv = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + grid-template-areas: "left right"; +`; + +const LeftDiv = styled.div` + grid-area: left; +`; + +const RightDiv = styled.div` + grid-area: right; +`; + +function TestPage() { + return ( + <div> + <h1>Continue</h1> + <SideBySideDiv> + <LeftDiv> + <h2>Left</h2> + </LeftDiv> + <RightDiv> + <h2>Right</h2> + </RightDiv> + </SideBySideDiv> + </div> + ); +} diff --git a/extension/react-app/src/assets/Hubot-Sans.woff2 b/extension/react-app/src/assets/Hubot-Sans.woff2 Binary files differnew file mode 100644 index 00000000..5089fc47 --- /dev/null +++ b/extension/react-app/src/assets/Hubot-Sans.woff2 diff --git a/extension/react-app/src/assets/Mona-Sans.woff2 b/extension/react-app/src/assets/Mona-Sans.woff2 Binary files differnew file mode 100644 index 00000000..8208a500 --- /dev/null +++ b/extension/react-app/src/assets/Mona-Sans.woff2 diff --git a/extension/react-app/src/assets/react.svg b/extension/react-app/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/extension/react-app/src/assets/react.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file diff --git a/extension/react-app/src/components/CodeBlock.tsx b/extension/react-app/src/components/CodeBlock.tsx new file mode 100644 index 00000000..4c10ab23 --- /dev/null +++ b/extension/react-app/src/components/CodeBlock.tsx @@ -0,0 +1,57 @@ +import hljs from "highlight.js"; +import { useEffect } from "react"; +import styled from "styled-components"; +import { defaultBorderRadius, vscBackground } from "."; + +import { Clipboard } from "@styled-icons/heroicons-outline"; + +const StyledPre = styled.pre` + overflow: scroll; + border: 1px solid gray; + border-radius: ${defaultBorderRadius}; + background-color: ${vscBackground}; +`; + +const StyledCode = styled.code` + background-color: ${vscBackground}; +`; + +const StyledCopyButton = styled.button` + float: right; + border: none; + background-color: ${vscBackground}; + cursor: pointer; + padding: 0; + margin: 4px; + &:hover { + color: #fff; + } +`; + +function CopyButton(props: { textToCopy: string }) { + return ( + <> + <StyledCopyButton + onClick={() => { + navigator.clipboard.writeText(props.textToCopy); + }} + > + <Clipboard color="white" size="1.4em" /> + </StyledCopyButton> + </> + ); +} + +function CodeBlock(props: { language?: string; children: string }) { + useEffect(() => { + hljs.highlightAll(); + }, [props.children]); + return ( + <StyledPre> + <CopyButton textToCopy={props.children} /> + <StyledCode>{props.children}</StyledCode> + </StyledPre> + ); +} + +export default CodeBlock; diff --git a/extension/react-app/src/components/CodeMultiselect.tsx b/extension/react-app/src/components/CodeMultiselect.tsx new file mode 100644 index 00000000..626ae42f --- /dev/null +++ b/extension/react-app/src/components/CodeMultiselect.tsx @@ -0,0 +1,276 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; +import { Button, buttonColor, defaultBorderRadius, secondaryDark } from "."; +import { useSelector } from "react-redux"; +import { + selectDebugContext, + selectAllRangesInFiles, + selectRangesMask, +} from "../redux/selectors/debugContextSelectors"; +import "../highlight/dark.min.css"; +import hljs from "highlight.js"; +import { postVscMessage } from "../vscode"; +import { RootStore } from "../redux/store"; +import { useDispatch } from "react-redux"; +import { + addRangeInFile, + deleteRangeInFileAt, + toggleSelectionAt, + updateFileSystem, +} from "../redux/slices/debugContexSlice"; +import { RangeInFile } from "../../../src/client"; +import { readRangeInVirtualFileSystem } from "../util"; + +//#region Styled Components + +const MultiSelectContainer = styled.div` + border-radius: ${defaultBorderRadius}; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const MultiSelectHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: left; + border-bottom: 1px solid gray; + padding-left: 4px; + padding-right: 4px; + & p { + overflow-wrap: break-word; + word-wrap: break-word; + -ms-wrap-flow: break-word; + overflow: hidden; + } +`; + +const MultiSelectOption = styled.div` + border-radius: ${defaultBorderRadius}; + padding-top: 4px; + cursor: pointer; + background-color: ${secondaryDark}; +`; + +const DeleteSelectedRangeButton = styled(Button)` + align-self: right; + padding: 0px; + margin-top: 0; + aspect-ratio: 1/1; + height: 28px; +`; + +const ToggleHighlightButton = styled(Button)` + display: grid; + justify-content: center; + align-items: center; + grid-template-columns: 30px 1fr; + margin-left: 20px; + order: 1; + width: fit-content; +`; + +//#endregion + +//#region Path Formatting + +const filenameToLanguageMap: any = { + py: "python", + js: "javascript", + ts: "typescript", + html: "html", + css: "css", + java: "java", + c: "c", + cpp: "cpp", + cs: "csharp", + go: "go", + rb: "ruby", + rs: "rust", + swift: "swift", + php: "php", + scala: "scala", + kt: "kotlin", + dart: "dart", + hs: "haskell", + lua: "lua", + pl: "perl", + r: "r", + sql: "sql", + vb: "vb", + xml: "xml", + yaml: "yaml", +}; + +function filenameToLanguage(filename: string): string { + const extension = filename.split(".").pop(); + if (extension === undefined) { + return ""; + } + return filenameToLanguageMap[extension] || ""; +} + +function formatPathRelativeToWorkspace( + path: string, + workspacePath: string | undefined +) { + if (workspacePath === undefined) { + return path; + } + if (path.startsWith(workspacePath)) { + return path.substring(workspacePath.length + 1); + } else { + return path; + } +} + +function formatFileRange( + rangeInFile: RangeInFile, + workspacePath: string | undefined +) { + return `${formatPathRelativeToWorkspace( + rangeInFile.filepath, + workspacePath + )} (lines ${rangeInFile.range.start.line + 1}-${ + rangeInFile.range.end.line + 1 + })`; + // +1 because VSCode Ranges are 0-indexed +} + +//#endregion + +function CodeMultiselect(props: {}) { + // State + const [highlightLocked, setHighlightLocked] = useState(true); + + // Redux + const dispatch = useDispatch(); + const workspacePath = useSelector( + (state: RootStore) => state.config.workspacePath + ); + const debugContext = useSelector(selectDebugContext); + const rangesInFiles = useSelector(selectAllRangesInFiles); + const rangesInFilesMask = useSelector(selectRangesMask); + + useEffect(() => { + let eventListener = (event: any) => { + switch (event.data.type) { + case "highlightedCode": + if (!highlightLocked) { + dispatch( + addRangeInFile({ + rangeInFile: event.data.rangeInFile, + canUpdateLast: true, + }) + ); + dispatch(updateFileSystem(event.data.filesystem)); + } + break; + case "findSuspiciousCode": + for (let c of event.data.codeLocations) { + dispatch(addRangeInFile({ rangeInFile: c, canUpdateLast: false })); + } + dispatch(updateFileSystem(event.data.filesystem)); + postVscMessage("listTenThings", { debugContext }); + break; + } + }; + window.addEventListener("message", eventListener); + return () => window.removeEventListener("message", eventListener); + }, [debugContext, highlightLocked]); + + useEffect(() => { + hljs.highlightAll(); + }, [rangesInFiles]); + + return ( + <MultiSelectContainer> + {rangesInFiles.map((range: RangeInFile, index: number) => { + return ( + <MultiSelectOption + key={index} + style={{ + border: `1px solid ${ + rangesInFilesMask[index] ? buttonColor : "gray" + }`, + }} + onClick={() => { + dispatch(toggleSelectionAt(index)); + }} + > + <MultiSelectHeader> + <p style={{ margin: "4px" }}> + {formatFileRange(range, workspacePath)} + </p> + <DeleteSelectedRangeButton + onClick={() => dispatch(deleteRangeInFileAt(index))} + > + x + </DeleteSelectedRangeButton> + </MultiSelectHeader> + <pre> + <code + className={"language-" + filenameToLanguage(range.filepath)} + > + {readRangeInVirtualFileSystem(range, debugContext.filesystem)} + </code> + </pre> + </MultiSelectOption> + ); + })} + {rangesInFiles.length === 0 && ( + <> + <p>Highlight relevant code in the editor.</p> + </> + )} + <ToggleHighlightButton + onClick={() => { + setHighlightLocked(!highlightLocked); + }} + > + {highlightLocked ? ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + width="20px" + fill="none" + viewBox="0 0 24 24" + strokeWidth="1.5" + stroke="currentColor" + className="w-6 h-6" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" + /> + </svg>{" "} + Enable Highlight + </> + ) : ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + width="20px" + fill="none" + viewBox="0 0 24 24" + strokeWidth="1.5" + stroke="currentColor" + className="w-6 h-6" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" + /> + </svg>{" "} + Disable Highlight + </> + )} + </ToggleHighlightButton> + </MultiSelectContainer> + ); +} + +export default CodeMultiselect; diff --git a/extension/react-app/src/components/ContinueButton.tsx b/extension/react-app/src/components/ContinueButton.tsx new file mode 100644 index 00000000..11dc7a92 --- /dev/null +++ b/extension/react-app/src/components/ContinueButton.tsx @@ -0,0 +1,37 @@ +import styled, { keyframes } from "styled-components"; +import { Button } from "."; +import { Play } from "@styled-icons/heroicons-outline"; + +let StyledButton = styled(Button)` + margin: auto; + display: grid; + grid-template-columns: 30px 1fr; + align-items: center; + background: linear-gradient( + 95.23deg, + #be1a55 14.44%, + rgba(203, 27, 90, 0.4) 82.21% + ); + + &:hover { + transition-delay: 0.5s; + transition-property: background; + background: linear-gradient( + 45deg, + #be1a55 14.44%, + rgba(203, 27, 90, 0.4) 82.21% + ); + } +`; + +function ContinueButton(props: { onClick?: () => void }) { + return ( + <StyledButton className="m-auto" onClick={props.onClick}> + <Play /> + {/* <img src={"/continue_arrow.png"} width="16px"></img> */} + Continue + </StyledButton> + ); +} + +export default ContinueButton; diff --git a/extension/react-app/src/components/DebugPanel.tsx b/extension/react-app/src/components/DebugPanel.tsx new file mode 100644 index 00000000..ed00571b --- /dev/null +++ b/extension/react-app/src/components/DebugPanel.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; +import { postVscMessage } from "../vscode"; +import { useDispatch } from "react-redux"; +import { + setApiUrl, + setVscMachineId, + setSessionId, +} from "../redux/slices/configSlice"; +import { setHighlightedCode } from "../redux/slices/miscSlice"; +import { updateFileSystem } from "../redux/slices/debugContexSlice"; +import { buttonColor, defaultBorderRadius, vscBackground } from "."; +interface DebugPanelProps { + tabs: { + element: React.ReactElement; + title: string; + }[]; +} + +const GradientContainer = styled.div` + // Uncomment to get gradient border + background: linear-gradient( + 101.79deg, + #12887a 0%, + #87245c 37.64%, + #e12637 65.98%, + #ffb215 110.45% + ); + /* padding: 10px; */ + margin: 0; + height: 100%; + /* border: 1px solid white; */ + border-radius: ${defaultBorderRadius}; +`; + +const MainDiv = styled.div` + height: 100%; + border-radius: ${defaultBorderRadius}; + overflow: scroll; + scrollbar-base-color: transparent; + /* background: ${vscBackground}; */ + background-color: #1e1e1ede; +`; + +const TabBar = styled.div<{ numTabs: number }>` + display: grid; + grid-template-columns: repeat(${(props) => props.numTabs}, 1fr); +`; + +const TabsAndBodyDiv = styled.div` + display: grid; + grid-template-rows: auto 1fr; + height: 100%; +`; + +function DebugPanel(props: DebugPanelProps) { + const dispatch = useDispatch(); + useEffect(() => { + const eventListener = (event: any) => { + switch (event.data.type) { + case "onLoad": + dispatch(setApiUrl(event.data.apiUrl)); + dispatch(setVscMachineId(event.data.vscMachineId)); + dispatch(setSessionId(event.data.sessionId)); + break; + case "highlightedCode": + dispatch(setHighlightedCode(event.data.rangeInFile)); + dispatch(updateFileSystem(event.data.filesystem)); + break; + } + }; + window.addEventListener("message", eventListener); + postVscMessage("onLoad", {}); + return () => window.removeEventListener("message", eventListener); + }, []); + + const [currentTab, setCurrentTab] = useState(0); + + return ( + <GradientContainer> + <MainDiv> + <TabsAndBodyDiv> + {props.tabs.length > 1 && ( + <TabBar numTabs={props.tabs.length}> + {props.tabs.map((tab, index) => { + return ( + <div + key={index} + className={`p-2 cursor-pointer text-center ${ + index === currentTab + ? "bg-secondary-dark" + : "bg-vsc-background" + }`} + onClick={() => setCurrentTab(index)} + > + {tab.title} + </div> + ); + })} + </TabBar> + )} + {props.tabs.map((tab, index) => { + return ( + <div + key={index} + hidden={index !== currentTab} + className={ + tab.title === "Chat" ? "overflow-hidden" : "overflow-scroll" + } + > + {tab.element} + </div> + ); + })} + </TabsAndBodyDiv> + </MainDiv> + </GradientContainer> + ); +} + +export default DebugPanel; diff --git a/extension/react-app/src/components/IterationContainer.tsx b/extension/react-app/src/components/IterationContainer.tsx new file mode 100644 index 00000000..a0053519 --- /dev/null +++ b/extension/react-app/src/components/IterationContainer.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import styled from "styled-components"; +import { + defaultBorderRadius, + MainContainerWithBorder, + secondaryDark, + vscBackground, +} from "."; +import { RangeInFile, FileEdit } from "../../../src/client"; +import CodeBlock from "./CodeBlock"; +import SubContainer from "./SubContainer"; + +import { ChevronDown, ChevronRight } from "@styled-icons/heroicons-outline"; + +export interface IterationContext { + codeSelections: RangeInFile[]; + instruction: string; + suggestedChanges: FileEdit[]; + status: "waiting" | "accepted" | "rejected"; + summary?: string; + action: string; + error?: string; +} + +interface IterationContainerProps { + iterationContext: IterationContext; +} + +const IterationContainerDiv = styled.div<{ open: boolean }>` + background-color: ${(props) => (props.open ? vscBackground : secondaryDark)}; + border-radius: ${defaultBorderRadius}; + padding: ${(props) => (props.open ? "2px" : "8px")}; +`; + +function IterationContainer(props: IterationContainerProps) { + const [open, setOpen] = useState(false); + + return ( + <MainContainerWithBorder className="m-2 overflow-hidden"> + <IterationContainerDiv open={open}> + <p + className="m-2 cursor-pointer" + onClick={() => setOpen((prev) => !prev)} + > + {open ? <ChevronDown size="1.4em" /> : <ChevronRight size="1.4em" />} + {props.iterationContext.summary || + props.iterationContext.codeSelections + .map((cs) => cs.filepath) + .join("\n")} + </p> + + {open && ( + <> + <SubContainer title="Action"> + {props.iterationContext.action} + </SubContainer> + {props.iterationContext.error && ( + <SubContainer title="Error"> + <CodeBlock>{props.iterationContext.error}</CodeBlock> + </SubContainer> + )} + {props.iterationContext.suggestedChanges.map((sc) => { + return ( + <SubContainer title="Suggested Change"> + {sc.filepath} + <CodeBlock>{sc.replacement}</CodeBlock> + </SubContainer> + ); + })} + </> + )} + </IterationContainerDiv> + </MainContainerWithBorder> + ); +} + +export default IterationContainer; diff --git a/extension/react-app/src/components/StepContainer.tsx b/extension/react-app/src/components/StepContainer.tsx new file mode 100644 index 00000000..03649b66 --- /dev/null +++ b/extension/react-app/src/components/StepContainer.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import styled, { keyframes } from "styled-components"; +import { + appear, + buttonColor, + defaultBorderRadius, + MainContainerWithBorder, + MainTextInput, + secondaryDark, + vscBackground, + GradientBorder, +} from "."; +import { RangeInFile, FileEdit } from "../../../src/client"; +import CodeBlock from "./CodeBlock"; +import SubContainer from "./SubContainer"; + +import { + ChevronDown, + ChevronRight, + Backward, +} from "@styled-icons/heroicons-outline"; +import { HistoryNode } from "../../../schema/HistoryNode"; +import ReactMarkdown from "react-markdown"; +import ContinueButton from "./ContinueButton"; + +interface StepContainerProps { + historyNode: HistoryNode; + onReverse: () => void; + inFuture: boolean; + onRefinement: (input: string) => void; + onUserInput: (input: string) => void; +} + +const MainDiv = styled.div<{ stepDepth: number; inFuture: boolean }>` + opacity: ${(props) => (props.inFuture ? 0.3 : 1)}; + animation: ${appear} 0.3s ease-in-out; + /* padding-left: ${(props) => props.stepDepth * 20}px; */ + overflow: hidden; +`; + +const StepContainerDiv = styled.div<{ open: boolean }>` + background-color: ${(props) => (props.open ? vscBackground : secondaryDark)}; + border-radius: ${defaultBorderRadius}; + padding: 8px; +`; + +const HeaderDiv = styled.div` + display: grid; + grid-template-columns: 1fr auto; + align-items: center; +`; + +const HeaderButton = styled.button` + background-color: transparent; + border: 1px solid white; + border-radius: ${defaultBorderRadius}; + padding: 2px; + cursor: pointer; + color: white; + + &:hover { + background-color: white; + color: black; + } +`; + +const OnHoverDiv = styled.div` + text-align: center; + padding: 10px; + animation: ${appear} 0.3s ease-in-out; +`; + +const NaturalLanguageInput = styled(MainTextInput)` + width: 80%; +`; + +function StepContainer(props: StepContainerProps) { + const [open, setOpen] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const naturalLanguageInputRef = useRef<HTMLTextAreaElement>(null); + + useEffect(() => { + if (isHovered) { + naturalLanguageInputRef.current?.focus(); + } + }, [isHovered]); + + const onTextInput = useCallback(() => { + if (naturalLanguageInputRef.current) { + props.onRefinement(naturalLanguageInputRef.current.value); + naturalLanguageInputRef.current.value = ""; + } + }, [naturalLanguageInputRef]); + + return ( + <MainDiv + stepDepth={(props.historyNode.depth as any) || 0} + inFuture={props.inFuture} + onMouseEnter={() => { + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + hidden={props.historyNode.step.hide as any} + > + <GradientBorder + className="m-2 overflow-hidden cursor-pointer" + onClick={() => setOpen((prev) => !prev)} + > + <StepContainerDiv open={open}> + <HeaderDiv> + <h4 className="m-2 cursor-pointer"> + {open ? ( + <ChevronDown size="1.4em" /> + ) : ( + <ChevronRight size="1.4em" /> + )} + {props.historyNode.step.name as any}: + </h4> + <HeaderButton + onClick={(e) => { + e.stopPropagation(); + props.onReverse(); + }} + > + <Backward size="1.6em" onClick={props.onReverse}></Backward> + </HeaderButton> + </HeaderDiv> + + <ReactMarkdown key={1} className="overflow-scroll"> + {props.historyNode.step.description as any} + </ReactMarkdown> + + {props.historyNode.step.name === "Waiting for user input" && ( + <input + className="m-auto p-2 rounded-md border-1 border-solid text-white w-3/4 border-gray-200 bg-vsc-background" + onKeyDown={(e) => { + if (e.key === "Enter") { + props.onUserInput(e.currentTarget.value); + } + }} + type="text" + onSubmit={(ev) => { + props.onUserInput(ev.currentTarget.value); + }} + /> + )} + {props.historyNode.step.name === "Waiting for user confirmation" && ( + <> + <input + type="button" + value="Cancel" + className="m-4 p-2 rounded-md border border-solid text-white border-gray-200 bg-vsc-background cursor-pointer hover:bg-white hover:text-black" + ></input> + <input + className="m-4 p-2 rounded-md border border-solid text-white border-gray-200 bg-vsc-background cursor-pointer hover:bg-white hover:text-black" + onClick={(e) => { + props.onUserInput("ok"); + e.preventDefault(); + e.stopPropagation(); + }} + type="button" + value="Confirm" + /> + </> + )} + + {open && ( + <> + {/* {props.historyNode.observation && ( + <SubContainer title="Error"> + <CodeBlock>Error Here</CodeBlock> + </SubContainer> + )} */} + {/* {props.iterationContext.suggestedChanges.map((sc) => { + return ( + <SubContainer title="Suggested Change"> + {sc.filepath} + <CodeBlock>{sc.replacement}</CodeBlock> + </SubContainer> + ); + })} */} + </> + )} + </StepContainerDiv> + </GradientBorder> + + <OnHoverDiv hidden={!open}> + <NaturalLanguageInput + onKeyDown={(e) => { + if (e.key === "Enter") { + onTextInput(); + } + }} + ref={naturalLanguageInputRef} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + ></NaturalLanguageInput> + <ContinueButton onClick={onTextInput}></ContinueButton> + </OnHoverDiv> + </MainDiv> + ); +} + +export default StepContainer; diff --git a/extension/react-app/src/components/SubContainer.tsx b/extension/react-app/src/components/SubContainer.tsx new file mode 100644 index 00000000..87e2094c --- /dev/null +++ b/extension/react-app/src/components/SubContainer.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import styled from "styled-components"; +import { defaultBorderRadius, secondaryDark, appear } from "."; + +const SubContainerDiv = styled.div` + margin: 4px; + padding: 8px; + border-radius: ${defaultBorderRadius}; + background-color: ${secondaryDark}; + + animation: ${appear} 0.3s ease-in-out; +`; + +function SubContainer(props: { children: React.ReactNode; title: string }) { + return ( + <SubContainerDiv> + <b className="mb-12">{props.title}</b> + <br></br> + {props.children} + </SubContainerDiv> + ); +} + +export default SubContainer; diff --git a/extension/react-app/src/components/VSCodeFileLink.tsx b/extension/react-app/src/components/VSCodeFileLink.tsx new file mode 100644 index 00000000..6219654d --- /dev/null +++ b/extension/react-app/src/components/VSCodeFileLink.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { postVscMessage } from "../vscode"; + +function VSCodeFileLink(props: { path: string; text?: string }) { + return ( + <a + href={`file://${props.path}`} + onClick={() => { + postVscMessage("openFile", { path: props.path }); + }} + > + {props.text || props.path} + </a> + ); +} + +export default VSCodeFileLink; diff --git a/extension/react-app/src/components/index.ts b/extension/react-app/src/components/index.ts new file mode 100644 index 00000000..e37c97f3 --- /dev/null +++ b/extension/react-app/src/components/index.ts @@ -0,0 +1,136 @@ +import styled, { keyframes } from "styled-components"; + +export const defaultBorderRadius = "5px"; +export const secondaryDark = "rgb(37 37 38)"; +export const vscBackground = "rgb(30 30 30)"; +export const buttonColor = "rgb(113 28 59)"; +export const buttonColorHover = "rgb(113 28 59 0.67)"; + +export const Button = styled.button` + padding: 10px 12px; + margin: 8px 0; + border-radius: ${defaultBorderRadius}; + cursor: pointer; + + border: none; + color: white; + background-color: ${buttonColor}; + + &:disabled { + color: gray; + } + + &:hover:enabled { + background-color: ${buttonColorHover}; + } +`; + +export const TextArea = styled.textarea` + width: 100%; + border-radius: ${defaultBorderRadius}; + border: none; + background-color: ${secondaryDark}; + resize: vertical; + + padding: 4px; + caret-color: white; + color: white; + + &:focus { + outline: 1px solid ${buttonColor}; + } +`; + +export const Pre = styled.pre` + border-radius: ${defaultBorderRadius}; + padding: 8px; + max-height: 150px; + overflow: scroll; + margin: 0; + background-color: ${secondaryDark}; + border: none; + + /* text wrapping */ + white-space: pre-wrap; /* Since CSS 2.1 */ + white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ + white-space: -pre-wrap; /* Opera 4-6 */ + white-space: -o-pre-wrap; /* Opera 7 */ + word-wrap: break-word; /* Internet Explorer 5.5+ */ +`; + +export const H3 = styled.h3` + background-color: ${secondaryDark}; + border-radius: ${defaultBorderRadius}; + padding: 4px 8px; + width: fit-content; +`; + +export const TextInput = styled.input.attrs({ type: "text" })` + width: 100%; + padding: 12px 20px; + margin: 8px 0; + box-sizing: border-box; + border-radius: ${defaultBorderRadius}; + border: 2px solid gray; +`; + +const spin = keyframes` + from { + -webkit-transform: rotate(0deg); + } + to { + -webkit-transform: rotate(360deg); + } +`; + +export const Loader = styled.div` + border: 4px solid transparent; + border-radius: 50%; + border-top: 4px solid white; + width: 36px; + height: 36px; + -webkit-animation: ${spin} 1s ease-in-out infinite; + animation: ${spin} 1s ease-in-out infinite; + margin: auto; +`; + +export const GradientBorder = styled.div<{ borderWidth?: string }>` + border-radius: ${defaultBorderRadius}; + padding: ${(props) => props.borderWidth || "1px"}; + background: linear-gradient( + 101.79deg, + #12887a 0%, + #87245c 37.64%, + #e12637 65.98%, + #ffb215 110.45% + ); +`; + +export const MainContainerWithBorder = styled.div<{ borderWidth?: string }>` + border-radius: ${defaultBorderRadius}; + padding: ${(props) => props.borderWidth || "1px"}; + background-color: white; +`; + +export const MainTextInput = styled.textarea` + padding: 8px; + font-size: 16px; + border-radius: ${defaultBorderRadius}; + border: 1px solid #ccc; + margin: 8px 8px; + background-color: ${vscBackground}; + color: white; + outline: 1px solid orange; + resize: none; +`; + +export const appear = keyframes` + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0px); + } +`; diff --git a/extension/react-app/src/continue_arrow.png b/extension/react-app/src/continue_arrow.png Binary files differnew file mode 100644 index 00000000..3b16ddf9 --- /dev/null +++ b/extension/react-app/src/continue_arrow.png diff --git a/extension/react-app/src/highlight/dark.min.css b/extension/react-app/src/highlight/dark.min.css new file mode 100644 index 00000000..9268d7c9 --- /dev/null +++ b/extension/react-app/src/highlight/dark.min.css @@ -0,0 +1,53 @@ +pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em; +} +code.hljs { + padding: 3px 5px; +} +.hljs { + color: #ddd; + background: #252526; +} +.hljs-keyword, +.hljs-link, +.hljs-literal, +.hljs-section, +.hljs-selector-tag { + color: #fff; +} +.hljs-addition, +.hljs-attribute, +.hljs-built_in, +.hljs-bullet, +.hljs-name, +.hljs-string, +.hljs-symbol, +.hljs-template-tag, +.hljs-template-variable, +.hljs-title, +.hljs-type, +.hljs-variable { + color: #d88; +} +.hljs-comment, +.hljs-deletion, +.hljs-meta, +.hljs-quote { + color: #979797; +} +.hljs-doctag, +.hljs-keyword, +.hljs-literal, +.hljs-name, +.hljs-section, +.hljs-selector-tag, +.hljs-strong, +.hljs-title, +.hljs-type { + font-weight: 700; +} +.hljs-emphasis { + font-style: italic; +} diff --git a/extension/react-app/src/hooks/useArrayState.ts b/extension/react-app/src/hooks/useArrayState.ts new file mode 100644 index 00000000..d379e720 --- /dev/null +++ b/extension/react-app/src/hooks/useArrayState.ts @@ -0,0 +1,29 @@ +import { useState } from "react"; + +function useArrayState<T>(initialValue: T[]) { + const [value, setValue] = useState(initialValue); + + function add(item: any) { + setValue((prev) => [...prev, item]); + } + + function remove(index: number) { + setValue((prev) => prev.filter((_, i) => i !== index)); + } + + function edit(editFn: (prev: T[]) => T[]) { + setValue((prev) => editFn(prev)); + } + + function replace(atIndex: number, withItem: T) { + setValue((prev) => { + let updated = [...prev]; + updated[atIndex] = withItem; + return updated; + }); + } + + return { value, add, remove, edit, replace }; +} + +export default useArrayState; diff --git a/extension/react-app/src/hooks/useWebsocket.ts b/extension/react-app/src/hooks/useWebsocket.ts new file mode 100644 index 00000000..147172bd --- /dev/null +++ b/extension/react-app/src/hooks/useWebsocket.ts @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from "react"; +import { RootStore } from "../redux/store"; +import { useSelector } from "react-redux"; + +function useContinueWebsocket( + serverUrl: string, + onMessage: (message: { data: any }) => void +) { + const sessionId = useSelector((state: RootStore) => state.config.sessionId); + const [websocket, setWebsocket] = useState<WebSocket | undefined>(undefined); + + async function connect() { + while (!sessionId) { + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + console.log("Creating websocket", sessionId); + + const wsUrl = + serverUrl.replace("http", "ws") + + "/notebook/ws?session_id=" + + encodeURIComponent(sessionId); + + const ws = new WebSocket(wsUrl); + setWebsocket(ws); + + // Set up callbacks + ws.onopen = () => { + console.log("Websocket opened"); + ws.send(JSON.stringify({ sessionId })); + }; + + ws.onmessage = (msg) => { + onMessage(msg); + console.log("Got message", msg); + }; + + ws.onclose = (msg) => { + console.log("Websocket closed"); + setWebsocket(undefined); + }; + + return ws; + } + + async function getConnection() { + if (!websocket) { + return await connect(); + } + return websocket; + } + + async function send(message: object) { + let ws = await getConnection(); + ws.send(JSON.stringify(message)); + } + + useEffect(() => { + if (!sessionId) { + return; + } + connect(); + }, [sessionId]); + + return { send }; +} +export default useContinueWebsocket; diff --git a/extension/react-app/src/index.css b/extension/react-app/src/index.css new file mode 100644 index 00000000..dd38eec3 --- /dev/null +++ b/extension/react-app/src/index.css @@ -0,0 +1,34 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --secondary-dark: 37 37 38; + --vsc-background: 30 30 30; + --button-color: rgb(113, 28, 59); + --button-color-hover: rgba(113, 28, 59, 0.667); + --def-border-radius: 5px; +} + +@font-face { + font-family: "Mona Sans"; + src: url("assets/Mona-Sans.woff2") format("woff2 supports variations"), + url("assets/Mona-Sans.woff2") format("woff2-variations"); + font-weight: 200 900; + font-stretch: 75% 85%; +} + +html, +body, +#root { + height: calc(100% - 7px); +} + +body { + background-color: var(--vsc-background); + padding: 0; + color: white; + font-family: "Mona Sans", "Arial", sans-serif; + padding: 0px; + margin: 0px; +} diff --git a/extension/react-app/src/main.tsx b/extension/react-app/src/main.tsx new file mode 100644 index 00000000..791f139e --- /dev/null +++ b/extension/react-app/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + <React.StrictMode> + <App /> + </React.StrictMode>, +) diff --git a/extension/react-app/src/redux/hooks.ts b/extension/react-app/src/redux/hooks.ts new file mode 100644 index 00000000..a6aef869 --- /dev/null +++ b/extension/react-app/src/redux/hooks.ts @@ -0,0 +1,21 @@ +import { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { RootStore } from "./store"; +import { selectDebugContextValue } from "./selectors/debugContextSelectors"; +import { updateValue } from "./slices/debugContexSlice"; +import { SerializedDebugContext } from "../../../src/client"; + +export function useDebugContextValue( + key: keyof SerializedDebugContext, + defaultValue: any +): [any, (value: any) => void] { + const dispatch = useDispatch(); + const state = + useSelector((state: RootStore) => selectDebugContextValue(state, key)) || + defaultValue; + const boundAction = useCallback( + (value: any) => dispatch(updateValue({ key, value })), + [dispatch, key] + ); + return [state, boundAction]; +} diff --git a/extension/react-app/src/redux/selectors/chatSelectors.ts b/extension/react-app/src/redux/selectors/chatSelectors.ts new file mode 100644 index 00000000..51e8a636 --- /dev/null +++ b/extension/react-app/src/redux/selectors/chatSelectors.ts @@ -0,0 +1,11 @@ +import { RootStore } from "../store"; + +const selectChatMessages = (state: RootStore) => { + return state.chat.messages; +}; + +const selectIsStreaming = (state: RootStore) => { + return state.chat.isStreaming; +}; + +export { selectChatMessages, selectIsStreaming }; diff --git a/extension/react-app/src/redux/selectors/debugContextSelectors.ts b/extension/react-app/src/redux/selectors/debugContextSelectors.ts new file mode 100644 index 00000000..89201bb7 --- /dev/null +++ b/extension/react-app/src/redux/selectors/debugContextSelectors.ts @@ -0,0 +1,29 @@ +import { RootStore } from "../store"; + +const selectDebugContext = (state: RootStore) => { + return { + ...state.debugState.debugContext, + rangesInFiles: state.debugState.debugContext.rangesInFiles.filter( + (_, index) => state.debugState.rangesMask[index] + ), + }; +}; + +const selectAllRangesInFiles = (state: RootStore) => { + return state.debugState.debugContext.rangesInFiles; +}; + +const selectRangesMask = (state: RootStore) => { + return state.debugState.rangesMask; +}; + +const selectDebugContextValue = (state: RootStore, key: string) => { + return (state.debugState.debugContext as any)[key]; +}; + +export { + selectDebugContext, + selectDebugContextValue, + selectAllRangesInFiles, + selectRangesMask, +}; diff --git a/extension/react-app/src/redux/selectors/miscSelectors.ts b/extension/react-app/src/redux/selectors/miscSelectors.ts new file mode 100644 index 00000000..7dbaed09 --- /dev/null +++ b/extension/react-app/src/redux/selectors/miscSelectors.ts @@ -0,0 +1,5 @@ +import { RootStore } from "../store"; + +const selectHighlightedCode = (state: RootStore) => state.misc.highlightedCode; + +export { selectHighlightedCode }; diff --git a/extension/react-app/src/redux/slices/chatSlice.ts b/extension/react-app/src/redux/slices/chatSlice.ts new file mode 100644 index 00000000..848cccd4 --- /dev/null +++ b/extension/react-app/src/redux/slices/chatSlice.ts @@ -0,0 +1,93 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { ChatMessage, RootStore } from "../store"; + +export const chatSlice = createSlice({ + name: "chat", + initialState: { + messages: [], + isStreaming: false, + } as RootStore["chat"], + reducers: { + addMessage: ( + state, + action: { + type: string; + payload: ChatMessage; + } + ) => { + return { + ...state, + messages: [...state.messages, action.payload], + }; + }, + setIsStreaming: (state, action) => { + return { + ...state, + isStreaming: action.payload, + }; + }, + streamUpdate: (state, action) => { + if (!state.isStreaming) { + return { + ...state, + messages: [ + ...state.messages, + { + role: "assistant", + content: action.payload, + }, + ], + isStreaming: true, + }; + } else { + let lastMessage = state.messages[state.messages.length - 1]; + if (lastMessage.role !== "assistant") { + return { + ...state, + messages: [ + ...state.messages, + { + role: "assistant", + content: action.payload, + }, + ], + isStreaming: true, + }; + } + return { + ...state, + messages: [ + ...state.messages.slice(0, state.messages.length - 1), + { + ...lastMessage, + content: lastMessage.content + action.payload, + }, + ], + isStreaming: true, + }; + } + }, + closeStream: (state) => { + return { + ...state, + isStreaming: false, + }; + }, + clearChat: (state) => { + return { + ...state, + messages: [], + isStreaming: false, + }; + }, + }, +}); + +export const { + addMessage, + streamUpdate, + closeStream, + clearChat, + setIsStreaming, +} = chatSlice.actions; +export default chatSlice.reducer; diff --git a/extension/react-app/src/redux/slices/configSlice.ts b/extension/react-app/src/redux/slices/configSlice.ts new file mode 100644 index 00000000..a6a641e6 --- /dev/null +++ b/extension/react-app/src/redux/slices/configSlice.ts @@ -0,0 +1,45 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { RootStore } from "../store"; + +export const configSlice = createSlice({ + name: "config", + initialState: { + apiUrl: "http://localhost:8000", + } as RootStore["config"], + reducers: { + setWorkspacePath: ( + state: RootStore["config"], + action: { type: string; payload: string } + ) => { + return { + ...state, + workspacePath: action.payload, + }; + }, + setApiUrl: ( + state: RootStore["config"], + action: { type: string; payload: string } + ) => ({ + ...state, + apiUrl: action.payload, + }), + setVscMachineId: ( + state: RootStore["config"], + action: { type: string; payload: string } + ) => ({ + ...state, + vscMachineId: action.payload, + }), + setSessionId: ( + state: RootStore["config"], + action: { type: string; payload: string } + ) => ({ + ...state, + sessionId: action.payload, + }), + }, +}); + +export const { setVscMachineId, setApiUrl, setWorkspacePath, setSessionId } = + configSlice.actions; +export default configSlice.reducer; diff --git a/extension/react-app/src/redux/slices/debugContexSlice.ts b/extension/react-app/src/redux/slices/debugContexSlice.ts new file mode 100644 index 00000000..647440d5 --- /dev/null +++ b/extension/react-app/src/redux/slices/debugContexSlice.ts @@ -0,0 +1,149 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { RangeInFile, SerializedDebugContext } from "../../../../src/client"; +import { RootStore } from "../store"; + +export const debugStateSlice = createSlice({ + name: "debugState", + initialState: { + debugContext: { + rangesInFiles: [], + filesystem: {}, + traceback: undefined, + description: undefined, + }, + rangesMask: [], + } as RootStore["debugState"], + reducers: { + updateValue: ( + state: RootStore["debugState"], + action: { + type: string; + payload: { key: keyof SerializedDebugContext; value: any }; + } + ) => { + return { + ...state, + debugContext: { + ...state.debugContext, + [action.payload.key]: action.payload.value, + }, + }; + }, + addRangeInFile: ( + state: RootStore["debugState"], + action: { + type: string; + payload: { + rangeInFile: RangeInFile; + canUpdateLast: boolean; + }; + } + ) => { + let rangesInFiles = state.debugContext.rangesInFiles; + // If identical to existing range, don't add. Ideally you check for overlap of ranges. + for (let range of rangesInFiles) { + if ( + range.filepath === action.payload.rangeInFile.filepath && + range.range.start.line === + action.payload.rangeInFile.range.start.line && + range.range.end.line === action.payload.rangeInFile.range.end.line + ) { + return state; + } + } + + if ( + action.payload.canUpdateLast && + rangesInFiles.length > 0 && + rangesInFiles[rangesInFiles.length - 1].filepath === + action.payload.rangeInFile.filepath + ) { + return { + ...state, + debugContext: { + ...state.debugContext, + rangesInFiles: [ + ...rangesInFiles.slice(0, rangesInFiles.length - 1), + action.payload.rangeInFile, + ], + }, + }; + } else { + return { + ...state, + debugContext: { + ...state.debugContext, + rangesInFiles: [ + ...state.debugContext.rangesInFiles, + action.payload.rangeInFile, + ], + }, + rangesMask: [...state.rangesMask, true], + }; + } + }, + deleteRangeInFileAt: ( + state: RootStore["debugState"], + action: { + type: string; + payload: number; + } + ) => { + return { + ...state, + debugContext: { + ...state.debugContext, + rangesInFiles: state.debugContext.rangesInFiles.filter( + (_, index) => index !== action.payload + ), + }, + rangesMask: state.rangesMask.filter( + (_, index) => index !== action.payload + ), + }; + }, + toggleSelectionAt: ( + state: RootStore["debugState"], + action: { + type: string; + payload: number; + } + ) => { + return { + ...state, + rangesMask: state.rangesMask.map((_, index) => + index === action.payload + ? !state.rangesMask[index] + : state.rangesMask[index] + ), + }; + }, + updateFileSystem: ( + state: RootStore["debugState"], + action: { + type: string; + payload: { [filepath: string]: string }; + } + ) => { + return { + ...state, + debugContext: { + ...state.debugContext, + filesystem: { + ...state.debugContext.filesystem, + ...action.payload, + }, + }, + }; + }, + }, +}); + +export const { + updateValue, + updateFileSystem, + addRangeInFile, + deleteRangeInFileAt, + toggleSelectionAt, +} = debugStateSlice.actions; +export default debugStateSlice.reducer; diff --git a/extension/react-app/src/redux/slices/miscSlice.ts b/extension/react-app/src/redux/slices/miscSlice.ts new file mode 100644 index 00000000..c59cc4eb --- /dev/null +++ b/extension/react-app/src/redux/slices/miscSlice.ts @@ -0,0 +1,16 @@ +import { createSlice } from "@reduxjs/toolkit"; + +export const miscSlice = createSlice({ + name: "misc", + initialState: { + highlightedCode: "", + }, + reducers: { + setHighlightedCode: (state, action) => { + state.highlightedCode = action.payload; + }, + }, +}); + +export const { setHighlightedCode } = miscSlice.actions; +export default miscSlice.reducer; diff --git a/extension/react-app/src/redux/store.ts b/extension/react-app/src/redux/store.ts new file mode 100644 index 00000000..f9eb0517 --- /dev/null +++ b/extension/react-app/src/redux/store.ts @@ -0,0 +1,43 @@ +import { configureStore } from "@reduxjs/toolkit"; +import debugStateReducer from "./slices/debugContexSlice"; +import chatReducer from "./slices/chatSlice"; +import configReducer from "./slices/configSlice"; +import miscReducer from "./slices/miscSlice"; +import { RangeInFile, SerializedDebugContext } from "../../../src/client"; + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface RootStore { + debugState: { + debugContext: SerializedDebugContext; + rangesMask: boolean[]; + }; + config: { + workspacePath: string | undefined; + apiUrl: string; + vscMachineId: string | undefined; + sessionId: string | undefined; + sessionStarted: number | undefined; + }; + chat: { + messages: ChatMessage[]; + isStreaming: boolean; + }; + misc: { + highlightedCode: RangeInFile | undefined; + }; +} + +const store = configureStore({ + reducer: { + debugState: debugStateReducer, + chat: chatReducer, + config: configReducer, + misc: miscReducer, + }, +}); + +export default store; diff --git a/extension/react-app/src/tabs/additionalContext.tsx b/extension/react-app/src/tabs/additionalContext.tsx new file mode 100644 index 00000000..98fce9f1 --- /dev/null +++ b/extension/react-app/src/tabs/additionalContext.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { H3, TextArea } from "../components"; + +function AdditionalContextTab() { + return ( + <div className="mx-5"> + <H3>Additional Context</H3> + <TextArea + rows={8} + placeholder="Copy and paste information related to the bug from GitHub Issues, Slack threads, or other notes here." + className="additionalContextTextarea" + ></TextArea> + <br></br> + </div> + ); +} + +export default AdditionalContextTab; diff --git a/extension/react-app/src/tabs/chat/MessageDiv.tsx b/extension/react-app/src/tabs/chat/MessageDiv.tsx new file mode 100644 index 00000000..ab632220 --- /dev/null +++ b/extension/react-app/src/tabs/chat/MessageDiv.tsx @@ -0,0 +1,73 @@ +import React, { useEffect } from "react"; +import { ChatMessage } from "../../redux/store"; +import styled from "styled-components"; +import { + buttonColor, + defaultBorderRadius, + secondaryDark, +} from "../../components"; +import VSCodeFileLink from "../../components/VSCodeFileLink"; +import ReactMarkdown from "react-markdown"; +import "../../highlight/dark.min.css"; +import hljs from "highlight.js"; +import { useSelector } from "react-redux"; +import { selectIsStreaming } from "../../redux/selectors/chatSelectors"; + +const Container = styled.div` + padding-left: 8px; + padding-right: 8px; + border-radius: 8px; + margin: 3px; + width: fit-content; + max-width: 75%; + overflow: scroll; + word-wrap: break-word; + -ms-word-wrap: break-word; + height: fit-content; + overflow: hidden; + background-color: ${(props) => { + if (props.role === "user") { + return buttonColor; + } else { + return secondaryDark; + } + }}; + float: ${(props) => { + if (props.role === "user") { + return "right"; + } else { + return "left"; + } + }}; + display: block; + + & pre { + border: 1px solid gray; + border-radius: ${defaultBorderRadius}; + } +`; + +function MessageDiv(props: ChatMessage) { + const [richContent, setRichContent] = React.useState<JSX.Element[]>([]); + const isStreaming = useSelector(selectIsStreaming); + + useEffect(() => { + if (!isStreaming) { + hljs.highlightAll(); + } + }, [richContent, isStreaming]); + + useEffect(() => { + setRichContent([<ReactMarkdown key={1}>{props.content}</ReactMarkdown>]); + }, [props.content]); + + return ( + <> + <div className="overflow-auto"> + <Container role={props.role}>{richContent}</Container> + </div> + </> + ); +} + +export default MessageDiv; diff --git a/extension/react-app/src/tabs/chat/index.tsx b/extension/react-app/src/tabs/chat/index.tsx new file mode 100644 index 00000000..a93ad4f9 --- /dev/null +++ b/extension/react-app/src/tabs/chat/index.tsx @@ -0,0 +1,267 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { selectChatMessages } from "../../redux/selectors/chatSelectors"; +import MessageDiv from "./MessageDiv"; +import styled from "styled-components"; +import { addMessage, setIsStreaming } from "../../redux/slices/chatSlice"; +import { AnyAction, Dispatch } from "@reduxjs/toolkit"; +import { closeStream, streamUpdate } from "../../redux/slices/chatSlice"; +import { ChatMessage, RootStore } from "../../redux/store"; +import { postVscMessage, vscRequest } from "../../vscode"; +import { defaultBorderRadius, Loader } from "../../components"; +import { selectHighlightedCode } from "../../redux/selectors/miscSelectors"; +import { readRangeInVirtualFileSystem } from "../../util"; +import { selectDebugContext } from "../../redux/selectors/debugContextSelectors"; + +let textEntryBarHeight = "30px"; + +const ChatContainer = styled.div` + display: grid; + grid-template-rows: 1fr auto; + height: 100%; +`; + +const BottomDiv = styled.div` + display: grid; + grid-template-rows: auto auto; +`; + +const BottomButton = styled.button( + (props: { active: boolean }) => ` + font-size: 10px; + border: none; + color: white; + margin-right: 4px; + cursor: pointer; + background-color: ${props.active ? "black" : "gray"}; + border-radius: ${defaultBorderRadius}; + padding: 8px; +` +); + +const TextEntryBar = styled.input` + height: ${textEntryBarHeight}; + border-bottom-left-radius: ${defaultBorderRadius}; + border-bottom-right-radius: ${defaultBorderRadius}; + padding: 8px; + border: 1px solid white; + background-color: black; + color: white; + outline: none; +`; + +function ChatTab() { + const dispatch = useDispatch(); + const chatMessages = useSelector(selectChatMessages); + const isStreaming = useSelector((state: RootStore) => state.chat.isStreaming); + const baseUrl = useSelector((state: RootStore) => state.config.apiUrl); + const debugContext = useSelector(selectDebugContext); + + const [includeHighlightedCode, setIncludeHighlightedCode] = useState(true); + const [writeToEditor, setWriteToEditor] = useState(false); + const [waitingForResponse, setWaitingForResponse] = useState(false); + + const highlightedCode = useSelector(selectHighlightedCode); + + const streamToStateThunk = useCallback( + (dispatch: Dispatch<AnyAction>, getResponse: () => Promise<Response>) => { + let streamToCursor = writeToEditor; + getResponse().then((resp) => { + setWaitingForResponse(false); + if (resp.body) { + resp.body.pipeTo( + new WritableStream({ + write(chunk) { + let update = new TextDecoder("utf-8").decode(chunk); + dispatch(streamUpdate(update)); + if (streamToCursor) { + postVscMessage("streamUpdate", { update }); + } + }, + close() { + dispatch(closeStream()); + if (streamToCursor) { + postVscMessage("closeStream", null); + } + }, + }) + ); + } + }); + }, + [writeToEditor] + ); + + const compileHiddenChatMessages = useCallback(async () => { + let messages: ChatMessage[] = []; + if ( + includeHighlightedCode && + highlightedCode?.filepath !== undefined && + highlightedCode?.range !== undefined && + debugContext.filesystem[highlightedCode.filepath] !== undefined + ) { + let fileContents = readRangeInVirtualFileSystem( + highlightedCode, + debugContext.filesystem + ); + if (fileContents) { + messages.push({ + role: "user", + content: fileContents, + }); + } + } else { + // Similarity search over workspace + let data = await vscRequest("queryEmbeddings", { + query: chatMessages[chatMessages.length - 1].content, + }); + let codeContextMessages = data.results.map( + (result: { id: string; document: string }) => { + let msg: ChatMessage = { + role: "user", + content: `File: ${result.id} \n ${result.document}`, + }; + return msg; + } + ); + codeContextMessages.push({ + role: "user", + content: + "Use the above code to help you answer the question below. Answer in asterisk bullet points, and give the full path whenever you reference files.", + }); + messages.push(...codeContextMessages); + } + + let systemMsgContent = writeToEditor + ? "Respond only with the exact code requested, no additional text." + : "Use the above code to help you answer the question below. Respond in markdown if using bullets or other special formatting, being sure to specify language for code blocks."; + + messages.push({ + role: "system", + content: systemMsgContent, + }); + return messages; + }, [highlightedCode, chatMessages, includeHighlightedCode, writeToEditor]); + + useEffect(() => { + if ( + chatMessages.length > 0 && + chatMessages[chatMessages.length - 1].role === "user" && + !isStreaming + ) { + dispatch(setIsStreaming(true)); + streamToStateThunk(dispatch, async () => { + if (chatMessages.length === 0) { + return new Promise((resolve, _) => resolve(new Response())); + } + let hiddenChatMessages = await compileHiddenChatMessages(); + let augmentedMessages = [ + ...chatMessages.slice(0, -1), + ...hiddenChatMessages, + chatMessages[chatMessages.length - 1], + ]; + console.log(augmentedMessages); + // The autogenerated client can't handle streams, so have to go raw + return fetch(`${baseUrl}/chat/complete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + messages: augmentedMessages, + }), + }); + }); + } + }, [chatMessages, dispatch, isStreaming, highlightedCode]); + + const chatMessagesDiv = useRef<HTMLDivElement>(null); + useEffect(() => { + // Scroll to bottom + let interval = setInterval(() => { + if (chatMessagesDiv.current && !waitingForResponse) { + chatMessagesDiv.current.scrollTop += Math.max( + 4, + 0.05 * chatMessagesDiv.current.scrollHeight - + chatMessagesDiv.current.clientHeight - + chatMessagesDiv.current.scrollTop + ); + if ( + chatMessagesDiv.current.scrollTop >= + chatMessagesDiv.current.scrollHeight - + chatMessagesDiv.current.clientHeight + ) { + clearInterval(interval); + } + } + }, 10); + }, [chatMessages, chatMessagesDiv, waitingForResponse]); + + return ( + <ChatContainer> + <div className="mx-5 overflow-y-scroll" ref={chatMessagesDiv}> + <h1>Chat</h1> + <hr></hr> + <div> + {chatMessages.length > 0 ? ( + chatMessages.map((message, idx) => { + return <MessageDiv key={idx} {...message}></MessageDiv>; + }) + ) : ( + <p className="text-gray-400 m-auto text-center"> + You can ask questions about your codebase or ask for code written + directly in the editor. + </p> + )} + {waitingForResponse && <Loader></Loader>} + </div> + </div> + + <BottomDiv> + <div className="h-12 bg-secondary-"> + <div className="flex items-center p-2"> + {/* <p className="mr-auto text-xs"> + Highlighted code is automatically included in your chat message. + </p> */} + <BottomButton + className="ml-auto" + active={writeToEditor} + onClick={() => { + setWriteToEditor(!writeToEditor); + }} + > + {writeToEditor ? "Writing to editor" : "Write to editor"} + </BottomButton> + + <BottomButton + active={includeHighlightedCode} + onClick={() => { + setIncludeHighlightedCode(!includeHighlightedCode); + }} + > + {includeHighlightedCode + ? "Including highlighted code" + : "Automatically finding relevant code"} + </BottomButton> + </div> + </div> + <TextEntryBar + type="text" + placeholder="Enter your message here" + onKeyDown={(e) => { + if (e.key === "Enter" && e.currentTarget.value !== "") { + console.log("Sending message", e.currentTarget.value); + dispatch( + addMessage({ content: e.currentTarget.value, role: "user" }) + ); + (e.target as any).value = ""; + setWaitingForResponse(true); + } + }} + ></TextEntryBar> + </BottomDiv> + </ChatContainer> + ); +} + +export default ChatTab; diff --git a/extension/react-app/src/tabs/main.tsx b/extension/react-app/src/tabs/main.tsx new file mode 100644 index 00000000..a8b3300d --- /dev/null +++ b/extension/react-app/src/tabs/main.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from "react"; +import { H3, TextArea, Button, Pre, Loader } from "../components"; +import styled from "styled-components"; +import { postVscMessage, withProgress } from "../vscode"; +import { useDebugContextValue } from "../redux/hooks"; +import CodeMultiselect from "../components/CodeMultiselect"; +import { useSelector } from "react-redux"; +import { selectDebugContext } from "../redux/selectors/debugContextSelectors"; +import { useDispatch } from "react-redux"; +import { updateValue } from "../redux/slices/debugContexSlice"; +import { setWorkspacePath } from "../redux/slices/configSlice"; +import { SerializedDebugContext } from "../../../src/client"; +import { useEditCache } from "../util/editCache"; +import { useApi } from "../util/api"; + +const ButtonDiv = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 4px; + margin: 4px; + flex-wrap: wrap; + + & button { + flex-grow: 1; + } +`; + +function MainTab(props: any) { + const dispatch = useDispatch(); + + const [suggestion, setSuggestion] = useState(""); + const [traceback, setTraceback] = useDebugContextValue("traceback", ""); + const [selectedRanges, setSelectedRanges] = useDebugContextValue( + "rangesInFiles", + [] + ); + + const editCache = useEditCache(); + const { debugApi } = useApi(); + + const [responseLoading, setResponseLoading] = useState(false); + + let debugContext = useSelector(selectDebugContext); + + useEffect(() => { + editCache.preloadEdit(debugContext); + }, [debugContext]); + + function postVscMessageWithDebugContext( + type: string, + overrideDebugContext: SerializedDebugContext | null = null + ) { + postVscMessage(type, { + debugContext: overrideDebugContext || debugContext, + }); + } + + function launchFindSuspiciousCode(newTraceback: string) { + // setTraceback's effects don't occur immediately, so we have to add it to the debug context manually + let updatedDebugContext = { + ...debugContext, + traceback: newTraceback, + }; + postVscMessageWithDebugContext("findSuspiciousCode", updatedDebugContext); + postVscMessageWithDebugContext("preloadEdit", updatedDebugContext); + } + + useEffect(() => { + const eventListener = (event: any) => { + switch (event.data.type) { + case "suggestFix": + case "explainCode": + case "listTenThings": + setSuggestion(event.data.value); + setResponseLoading(false); + break; + case "traceback": + setTraceback(event.data.value); + launchFindSuspiciousCode(event.data.value); + break; + case "workspacePath": + dispatch(setWorkspacePath(event.data.value)); + break; + } + }; + window.addEventListener("message", eventListener); + + return () => window.removeEventListener("message", eventListener); + }, [debugContext, selectedRanges]); + + return ( + <div className="mx-5"> + <h1>Debug Panel</h1> + + <H3>Code Sections</H3> + <CodeMultiselect></CodeMultiselect> + + <H3>Bug Description</H3> + <TextArea + id="bugDescription" + name="bugDescription" + className="bugDescription" + rows={4} + cols={50} + placeholder="Describe your bug..." + ></TextArea> + + <H3>Stack Trace</H3> + <TextArea + id="traceback" + className="traceback" + name="traceback" + rows={4} + cols={50} + placeholder="Paste stack trace here" + onChange={(e) => { + setTraceback(e.target.value); + dispatch(updateValue({ key: "traceback", value: e.target.value })); + // postVscMessageWithDebugContext("findSuspiciousCode"); + }} + onPaste={(e) => { + let pasted = e.clipboardData.getData("text"); + console.log("PASTED", pasted); + setTraceback(pasted); + launchFindSuspiciousCode(pasted); + }} + value={traceback} + ></TextArea> + + <select + hidden + id="relevantVars" + className="relevantVars" + name="relevantVars" + ></select> + + <ButtonDiv> + <Button + onClick={() => { + postVscMessageWithDebugContext("explainCode"); + setResponseLoading(true); + }} + > + Explain Code + </Button> + <Button + onClick={() => { + postVscMessageWithDebugContext("suggestFix"); + setResponseLoading(true); + }} + > + Generate Ideas + </Button> + <Button + disabled={selectedRanges.length === 0} + onClick={async () => { + withProgress("Generating Fix", async () => { + let edits = await editCache.getEdit(debugContext); + postVscMessage("makeEdit", { edits }); + }); + }} + > + Suggest Fix + </Button> + <Button + disabled={selectedRanges.length === 0} + onClick={() => { + postVscMessageWithDebugContext("generateUnitTest"); + }} + > + Create Test + </Button> + </ButtonDiv> + <Loader hidden={!responseLoading}></Loader> + + <Pre + className="fixSuggestion" + hidden={!(typeof suggestion === "string" && suggestion.length > 0)} + > + {suggestion} + </Pre> + + <br></br> + </div> + ); +} + +export default MainTab; diff --git a/extension/react-app/src/tabs/notebook.tsx b/extension/react-app/src/tabs/notebook.tsx new file mode 100644 index 00000000..a9c69c5b --- /dev/null +++ b/extension/react-app/src/tabs/notebook.tsx @@ -0,0 +1,285 @@ +import styled from "styled-components"; +import { + Button, + defaultBorderRadius, + vscBackground, + MainTextInput, + Loader, +} from "../components"; +import ContinueButton from "../components/ContinueButton"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { History } from "../../../schema/History"; +import { HistoryNode } from "../../../schema/HistoryNode"; +import StepContainer from "../components/StepContainer"; +import { useSelector } from "react-redux"; +import { RootStore } from "../redux/store"; +import useContinueWebsocket from "../hooks/useWebsocket"; + +let TopNotebookDiv = styled.div` + display: grid; + grid-template-columns: 1fr; +`; + +let UserInputQueueItem = styled.div` + border-radius: ${defaultBorderRadius}; + color: gray; + padding: 8px; + margin: 8px; + text-align: center; +`; + +interface NotebookProps { + firstObservation?: any; +} + +function Notebook(props: NotebookProps) { + const serverUrl = useSelector((state: RootStore) => state.config.apiUrl); + + const [waitingForSteps, setWaitingForSteps] = useState(false); + const [userInputQueue, setUserInputQueue] = useState<string[]>([]); + const [history, setHistory] = useState<History | undefined>(); + // { + // timeline: [ + // { + // step: { + // name: "RunCodeStep", + // cmd: "python3 /Users/natesesti/Desktop/continue/extension/examples/python/main.py", + // description: + // "Run `python3 /Users/natesesti/Desktop/continue/extension/examples/python/main.py`", + // }, + // output: [ + // { + // traceback: { + // frames: [ + // { + // filepath: + // "/Users/natesesti/Desktop/continue/extension/examples/python/main.py", + // lineno: 7, + // function: "<module>", + // code: "print(sum(first, second))", + // }, + // ], + // message: "unsupported operand type(s) for +: 'int' and 'str'", + // error_type: + // ' ^^^^^^^^^^^^^^^^^^\n File "/Users/natesesti/Desktop/continue/extension/examples/python/sum.py", line 2, in sum\n return a + b\n ~~^~~\nTypeError', + // full_traceback: + // "Traceback (most recent call last):\n File \"/Users/natesesti/Desktop/continue/extension/examples/python/main.py\", line 7, in <module>\n print(sum(first, second))\n ^^^^^^^^^^^^^^^^^^\n File \"/Users/natesesti/Desktop/continue/extension/examples/python/sum.py\", line 2, in sum\n return a + b\n ~~^~~\nTypeError: unsupported operand type(s) for +: 'int' and 'str'", + // }, + // }, + // null, + // ], + // }, + // { + // step: { + // name: "EditCodeStep", + // range_in_files: [ + // { + // filepath: + // "/Users/natesesti/Desktop/continue/extension/examples/python/main.py", + // range: { + // start: { + // line: 0, + // character: 0, + // }, + // end: { + // line: 6, + // character: 25, + // }, + // }, + // }, + // ], + // prompt: + // "I ran into this problem with my Python code:\n\n Traceback (most recent call last):\n File \"/Users/natesesti/Desktop/continue/extension/examples/python/main.py\", line 7, in <module>\n print(sum(first, second))\n ^^^^^^^^^^^^^^^^^^\n File \"/Users/natesesti/Desktop/continue/extension/examples/python/sum.py\", line 2, in sum\n return a + b\n ~~^~~\nTypeError: unsupported operand type(s) for +: 'int' and 'str'\n\n Below are the files that might need to be fixed:\n\n {code}\n\n This is what the code should be in order to avoid the problem:\n", + // description: + // "Editing files: /Users/natesesti/Desktop/continue/extension/examples/python/main.py", + // }, + // output: [ + // null, + // { + // reversible: true, + // actions: [ + // { + // reversible: true, + // filesystem: {}, + // filepath: + // "/Users/natesesti/Desktop/continue/extension/examples/python/main.py", + // range: { + // start: { + // line: 0, + // character: 0, + // }, + // end: { + // line: 6, + // character: 25, + // }, + // }, + // replacement: + // "\nfrom sum import sum\n\nfirst = 1\nsecond = 2\n\nprint(sum(first, second))", + // }, + // ], + // }, + // ], + // }, + // { + // step: { + // name: "SolveTracebackStep", + // traceback: { + // frames: [ + // { + // filepath: + // "/Users/natesesti/Desktop/continue/extension/examples/python/main.py", + // lineno: 7, + // function: "<module>", + // code: "print(sum(first, second))", + // }, + // ], + // message: "unsupported operand type(s) for +: 'int' and 'str'", + // error_type: + // ' ^^^^^^^^^^^^^^^^^^\n File "/Users/natesesti/Desktop/continue/extension/examples/python/sum.py", line 2, in sum\n return a + b\n ~~^~~\nTypeError', + // full_traceback: + // "Traceback (most recent call last):\n File \"/Users/natesesti/Desktop/continue/extension/examples/python/main.py\", line 7, in <module>\n print(sum(first, second))\n ^^^^^^^^^^^^^^^^^^\n File \"/Users/natesesti/Desktop/continue/extension/examples/python/sum.py\", line 2, in sum\n return a + b\n ~~^~~\nTypeError: unsupported operand type(s) for +: 'int' and 'str'", + // }, + // description: "Running step: SolveTracebackStep", + // }, + // output: [null, null], + // }, + // { + // step: { + // name: "RunCodeStep", + // cmd: "python3 /Users/natesesti/Desktop/continue/extension/examples/python/main.py", + // description: + // "Run `python3 /Users/natesesti/Desktop/continue/extension/examples/python/main.py`", + // }, + // output: [null, null], + // }, + // ], + // current_index: 0, + // } as any + // ); + + const { send: websocketSend } = useContinueWebsocket(serverUrl, (msg) => { + let data = JSON.parse(msg.data); + if (data.messageType === "state") { + setWaitingForSteps(data.state.active); + setHistory(data.state.history); + setUserInputQueue(data.state.user_input_queue); + } + }); + + // useEffect(() => { + // (async () => { + // if (sessionId && props.firstObservation) { + // let resp = await fetch(serverUrl + "/observation", { + // method: "POST", + // headers: new Headers({ + // "x-continue-session-id": sessionId, + // }), + // body: JSON.stringify({ + // observation: props.firstObservation, + // }), + // }); + // } + // })(); + // }, [props.firstObservation]); + + const mainTextInputRef = useRef<HTMLTextAreaElement>(null); + + useEffect(() => { + if (mainTextInputRef.current) { + mainTextInputRef.current.focus(); + let handler = (event: any) => { + if (event.data.type === "focusContinueInput") { + mainTextInputRef.current?.focus(); + } + }; + window.addEventListener("message", handler); + return () => { + window.removeEventListener("message", handler); + }; + } + }, [mainTextInputRef]); + + const onMainTextInput = () => { + if (mainTextInputRef.current) { + let value = mainTextInputRef.current.value; + setWaitingForSteps(true); + websocketSend({ + messageType: "main_input", + value: value, + }); + setUserInputQueue((queue) => { + return [...queue, value]; + }); + mainTextInputRef.current.value = ""; + mainTextInputRef.current.style.height = ""; + } + }; + + const onStepUserInput = (input: string, index: number) => { + console.log("Sending step user input", input, index); + websocketSend({ + messageType: "step_user_input", + value: input, + index, + }); + }; + + // const iterations = useSelector(selectIterations); + return ( + <TopNotebookDiv> + {history?.timeline.map((node: HistoryNode, index: number) => { + return ( + <StepContainer + key={index} + onUserInput={(input: string) => { + onStepUserInput(input, index); + }} + inFuture={index > history?.current_index} + historyNode={node} + onRefinement={(input: string) => { + websocketSend({ + messageType: "refinement_input", + value: input, + index, + }); + }} + onReverse={() => { + websocketSend({ + messageType: "reverse", + index, + }); + }} + /> + ); + })} + {waitingForSteps && <Loader></Loader>} + + <div> + {userInputQueue.map((input) => { + return <UserInputQueueItem>{input}</UserInputQueueItem>; + })} + </div> + + <MainTextInput + ref={mainTextInputRef} + onKeyDown={(e) => { + if (e.key === "Enter") { + onMainTextInput(); + e.stopPropagation(); + e.preventDefault(); + } + }} + rows={1} + onChange={() => { + let textarea = mainTextInputRef.current!; + textarea.style.height = ""; /* Reset the height*/ + textarea.style.height = + Math.min(textarea.scrollHeight - 15, 500) + "px"; + }} + ></MainTextInput> + <ContinueButton onClick={onMainTextInput}></ContinueButton> + </TopNotebookDiv> + ); +} + +export default Notebook; diff --git a/extension/react-app/src/tabs/welcome.tsx b/extension/react-app/src/tabs/welcome.tsx new file mode 100644 index 00000000..c29d260a --- /dev/null +++ b/extension/react-app/src/tabs/welcome.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +function WelcomeTab() { + return ( + <div className="mx-5"> + <h1>Welcome to Continue</h1> + + <p> + Learn more in the{" "} + <a href="https://www.notion.so/continue-dev/Continue-User-Guide-1c6ad99887d0474d9e42206f6c98efa4"> + Continue User Guide + </a>{" "} + </p> + <p>Send Nate or Ty your feedback:</p> + <p>1. What excites you about Continue?</p> + <p>2. What did you struggle with when using Continue?</p> + <p>3. How do you wish Continue worked?</p> + </div> + ); +} + +export default WelcomeTab; diff --git a/extension/react-app/src/util/api.ts b/extension/react-app/src/util/api.ts new file mode 100644 index 00000000..bdec1d20 --- /dev/null +++ b/extension/react-app/src/util/api.ts @@ -0,0 +1,43 @@ +import { + Configuration, + DebugApi, + UnittestApi, + ChatApi, +} from "../../../src/client"; +import { useSelector } from "react-redux"; +import { useEffect, useState } from "react"; +import { RootStore } from "../redux/store"; + +export function useApi() { + const apiUrl = useSelector((state: RootStore) => state.config.apiUrl); + const vscMachineId = useSelector( + (state: RootStore) => state.config.vscMachineId + ); + const [debugApi, setDebugApi] = useState<DebugApi>(); + const [unittestApi, setUnittestApi] = useState<UnittestApi>(); + const [chatApi, setChatApi] = useState<ChatApi>(); + + useEffect(() => { + if (apiUrl && vscMachineId) { + let config = new Configuration({ + basePath: apiUrl, + fetchApi: fetch, + middleware: [ + { + pre: async (context) => { + context.init.headers = { + ...context.init.headers, + "x-vsc-machine-id": vscMachineId, + }; + }, + }, + ], + }); + setDebugApi(new DebugApi(config)); + setUnittestApi(new UnittestApi(config)); + setChatApi(new ChatApi(config)); + } + }, [apiUrl, vscMachineId]); + + return { debugApi, unittestApi, chatApi }; +} diff --git a/extension/react-app/src/util/editCache.ts b/extension/react-app/src/util/editCache.ts new file mode 100644 index 00000000..b8071127 --- /dev/null +++ b/extension/react-app/src/util/editCache.ts @@ -0,0 +1,89 @@ +import { useApi } from "../util/api"; +import { FileEdit, SerializedDebugContext } from "../../../src/client"; +import { useCallback, useEffect, useState } from "react"; + +export function useEditCache() { + const { debugApi } = useApi(); + + const fetchNewEdit = useCallback( + async (debugContext: SerializedDebugContext) => { + return ( + await debugApi?.editEndpointDebugEditPost({ + serializedDebugContext: debugContext, + }) + )?.completion; + }, + [debugApi] + ); + + const [editCache, setEditCache] = useState(new EditCache(fetchNewEdit)); + + useEffect(() => { + setEditCache(new EditCache(fetchNewEdit)); + }, [fetchNewEdit]); + + return editCache; +} + +/** + * Stores preloaded edits, invalidating based off of debug context changes + */ +class EditCache { + private _lastDebugContext: SerializedDebugContext | undefined; + private _cachedEdits: FileEdit[] | undefined; + private _fetchNewEdit: ( + debugContext: SerializedDebugContext + ) => Promise<FileEdit[] | undefined>; + private _debounceTimer: NodeJS.Timeout | undefined; + + private _debugContextChanged(debugContext: SerializedDebugContext): boolean { + if (!this._lastDebugContext) { + return true; + } + + return ( + JSON.stringify(this._lastDebugContext) !== JSON.stringify(debugContext) + ); + } + + private _debugContextComplete(debugContext: SerializedDebugContext): boolean { + return debugContext.rangesInFiles.length > 0; + } + + public async preloadEdit(debugContext: SerializedDebugContext) { + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + } + if ( + this._debugContextComplete(debugContext) && + this._debugContextChanged(debugContext) + ) { + this._debounceTimer = setTimeout(async () => { + console.log("Preloading edits"); + this._cachedEdits = await this._fetchNewEdit(debugContext); + this._lastDebugContext = debugContext; + }, 200); + } + } + + public async getEdit( + debugContext: SerializedDebugContext + ): Promise<FileEdit[]> { + if (this._debugContextChanged(debugContext)) { + console.log("Cache miss"); + this._cachedEdits = await this._fetchNewEdit(debugContext); + } else { + console.log("Cache hit"); + } + + return this._cachedEdits!; + } + + constructor( + fetchNewEdit: ( + debugContext: SerializedDebugContext + ) => Promise<FileEdit[] | undefined> + ) { + this._fetchNewEdit = fetchNewEdit; + } +} diff --git a/extension/react-app/src/util/index.ts b/extension/react-app/src/util/index.ts new file mode 100644 index 00000000..458f9d95 --- /dev/null +++ b/extension/react-app/src/util/index.ts @@ -0,0 +1,27 @@ +import { RangeInFile } from "../../../src/client"; + +export function readRangeInVirtualFileSystem( + rangeInFile: RangeInFile, + filesystem: { [filepath: string]: string } +): string | undefined { + const range = rangeInFile.range; + + let data = filesystem[rangeInFile.filepath]; + if (data === undefined) { + console.log("File not found"); + return undefined; + } else { + let lines = data.toString().split("\n"); + if (range.start.line === range.end.line) { + return lines[rangeInFile.range.start.line].slice( + rangeInFile.range.start.character, + rangeInFile.range.end.character + ); + } else { + let firstLine = lines[range.start.line].slice(range.start.character); + let lastLine = lines[range.end.line].slice(0, range.end.character); + let middleLines = lines.slice(range.start.line + 1, range.end.line); + return [firstLine, ...middleLines, lastLine].join("\n"); + } + } +} diff --git a/extension/react-app/src/vite-env.d.ts b/extension/react-app/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/extension/react-app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/extension/react-app/src/vscode/index.ts b/extension/react-app/src/vscode/index.ts new file mode 100644 index 00000000..7e373cd9 --- /dev/null +++ b/extension/react-app/src/vscode/index.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import "vscode-webview"; + +declare const vscode: any; + +export function postVscMessage(type: string, data: any) { + if (typeof vscode === "undefined") { + return; + } + vscode.postMessage({ + type, + ...data, + }); +} + +export async function vscRequest(type: string, data: any): Promise<any> { + return new Promise((resolve) => { + const handler = (event: any) => { + if (event.data.type === type) { + window.removeEventListener("message", handler); + resolve(event.data); + } + }; + window.addEventListener("message", handler); + postVscMessage(type, data); + }); +} + +export function useVscMessageValue( + messageType: string | string[], + initialValue?: any +) { + const [value, setValue] = useState<any>(initialValue); + window.addEventListener("message", (event) => { + if (event.data.type === messageType) { + setValue(event.data.value); + } + }); + return [value, setValue]; +} + +export async function withProgress(title: string, fn: () => Promise<any>) { + postVscMessage("withProgress", { title, done: false }); + return fn().finally(() => { + postVscMessage("withProgress", { title, done: true }); + }); +} |