From 27ecedb02ef79ce53bf533e016b00462c44541be Mon Sep 17 00:00:00 2001 From: Nate Sesti Date: Tue, 23 May 2023 23:45:12 -0400 Subject: copying from old repo --- extension/react-app/src/App.tsx | 29 +++ extension/react-app/src/TestPage.tsx | 33 +++ extension/react-app/src/assets/Hubot-Sans.woff2 | Bin 0 -> 165932 bytes extension/react-app/src/assets/Mona-Sans.woff2 | Bin 0 -> 133748 bytes extension/react-app/src/assets/react.svg | 1 + extension/react-app/src/components/CodeBlock.tsx | 57 +++++ .../react-app/src/components/CodeMultiselect.tsx | 276 ++++++++++++++++++++ .../react-app/src/components/ContinueButton.tsx | 37 +++ extension/react-app/src/components/DebugPanel.tsx | 121 +++++++++ .../src/components/IterationContainer.tsx | 77 ++++++ .../react-app/src/components/StepContainer.tsx | 208 +++++++++++++++ .../react-app/src/components/SubContainer.tsx | 24 ++ .../react-app/src/components/VSCodeFileLink.tsx | 17 ++ extension/react-app/src/components/index.ts | 136 ++++++++++ extension/react-app/src/continue_arrow.png | Bin 0 -> 1350 bytes extension/react-app/src/highlight/dark.min.css | 53 ++++ extension/react-app/src/hooks/useArrayState.ts | 29 +++ extension/react-app/src/hooks/useWebsocket.ts | 67 +++++ extension/react-app/src/index.css | 34 +++ extension/react-app/src/main.tsx | 10 + extension/react-app/src/redux/hooks.ts | 21 ++ .../react-app/src/redux/selectors/chatSelectors.ts | 11 + .../src/redux/selectors/debugContextSelectors.ts | 29 +++ .../react-app/src/redux/selectors/miscSelectors.ts | 5 + extension/react-app/src/redux/slices/chatSlice.ts | 93 +++++++ .../react-app/src/redux/slices/configSlice.ts | 45 ++++ .../react-app/src/redux/slices/debugContexSlice.ts | 149 +++++++++++ extension/react-app/src/redux/slices/miscSlice.ts | 16 ++ extension/react-app/src/redux/store.ts | 43 ++++ extension/react-app/src/tabs/additionalContext.tsx | 18 ++ extension/react-app/src/tabs/chat/MessageDiv.tsx | 73 ++++++ extension/react-app/src/tabs/chat/index.tsx | 267 +++++++++++++++++++ extension/react-app/src/tabs/main.tsx | 189 ++++++++++++++ extension/react-app/src/tabs/notebook.tsx | 285 +++++++++++++++++++++ extension/react-app/src/tabs/welcome.tsx | 22 ++ extension/react-app/src/util/api.ts | 43 ++++ extension/react-app/src/util/editCache.ts | 89 +++++++ extension/react-app/src/util/index.ts | 27 ++ extension/react-app/src/vite-env.d.ts | 1 + extension/react-app/src/vscode/index.ts | 47 ++++ 40 files changed, 2682 insertions(+) create mode 100644 extension/react-app/src/App.tsx create mode 100644 extension/react-app/src/TestPage.tsx create mode 100644 extension/react-app/src/assets/Hubot-Sans.woff2 create mode 100644 extension/react-app/src/assets/Mona-Sans.woff2 create mode 100644 extension/react-app/src/assets/react.svg create mode 100644 extension/react-app/src/components/CodeBlock.tsx create mode 100644 extension/react-app/src/components/CodeMultiselect.tsx create mode 100644 extension/react-app/src/components/ContinueButton.tsx create mode 100644 extension/react-app/src/components/DebugPanel.tsx create mode 100644 extension/react-app/src/components/IterationContainer.tsx create mode 100644 extension/react-app/src/components/StepContainer.tsx create mode 100644 extension/react-app/src/components/SubContainer.tsx create mode 100644 extension/react-app/src/components/VSCodeFileLink.tsx create mode 100644 extension/react-app/src/components/index.ts create mode 100644 extension/react-app/src/continue_arrow.png create mode 100644 extension/react-app/src/highlight/dark.min.css create mode 100644 extension/react-app/src/hooks/useArrayState.ts create mode 100644 extension/react-app/src/hooks/useWebsocket.ts create mode 100644 extension/react-app/src/index.css create mode 100644 extension/react-app/src/main.tsx create mode 100644 extension/react-app/src/redux/hooks.ts create mode 100644 extension/react-app/src/redux/selectors/chatSelectors.ts create mode 100644 extension/react-app/src/redux/selectors/debugContextSelectors.ts create mode 100644 extension/react-app/src/redux/selectors/miscSelectors.ts create mode 100644 extension/react-app/src/redux/slices/chatSlice.ts create mode 100644 extension/react-app/src/redux/slices/configSlice.ts create mode 100644 extension/react-app/src/redux/slices/debugContexSlice.ts create mode 100644 extension/react-app/src/redux/slices/miscSlice.ts create mode 100644 extension/react-app/src/redux/store.ts create mode 100644 extension/react-app/src/tabs/additionalContext.tsx create mode 100644 extension/react-app/src/tabs/chat/MessageDiv.tsx create mode 100644 extension/react-app/src/tabs/chat/index.tsx create mode 100644 extension/react-app/src/tabs/main.tsx create mode 100644 extension/react-app/src/tabs/notebook.tsx create mode 100644 extension/react-app/src/tabs/welcome.tsx create mode 100644 extension/react-app/src/util/api.ts create mode 100644 extension/react-app/src/util/editCache.ts create mode 100644 extension/react-app/src/util/index.ts create mode 100644 extension/react-app/src/vite-env.d.ts create mode 100644 extension/react-app/src/vscode/index.ts (limited to 'extension/react-app/src') 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 ( + <> + + , + title: "Notebook", + }, + // { element: , title: "Debug Panel" }, + // { element: , title: "Welcome" }, + // { element: , title: "Chat" }, + ]} + > + + + ); +} + +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 ( +
+

Continue

+ + +

Left

+
+ +

Right

+
+
+
+ ); +} diff --git a/extension/react-app/src/assets/Hubot-Sans.woff2 b/extension/react-app/src/assets/Hubot-Sans.woff2 new file mode 100644 index 00000000..5089fc47 Binary files /dev/null and b/extension/react-app/src/assets/Hubot-Sans.woff2 differ diff --git a/extension/react-app/src/assets/Mona-Sans.woff2 b/extension/react-app/src/assets/Mona-Sans.woff2 new file mode 100644 index 00000000..8208a500 Binary files /dev/null and b/extension/react-app/src/assets/Mona-Sans.woff2 differ 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 @@ + \ 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 ( + <> + { + navigator.clipboard.writeText(props.textToCopy); + }} + > + + + + ); +} + +function CodeBlock(props: { language?: string; children: string }) { + useEffect(() => { + hljs.highlightAll(); + }, [props.children]); + return ( + + + {props.children} + + ); +} + +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 ( + + {rangesInFiles.map((range: RangeInFile, index: number) => { + return ( + { + dispatch(toggleSelectionAt(index)); + }} + > + +

+ {formatFileRange(range, workspacePath)} +

+ dispatch(deleteRangeInFileAt(index))} + > + x + +
+
+              
+                {readRangeInVirtualFileSystem(range, debugContext.filesystem)}
+              
+            
+
+ ); + })} + {rangesInFiles.length === 0 && ( + <> +

Highlight relevant code in the editor.

+ + )} + { + setHighlightLocked(!highlightLocked); + }} + > + {highlightLocked ? ( + <> + + + {" "} + Enable Highlight + + ) : ( + <> + + + {" "} + Disable Highlight + + )} + +
+ ); +} + +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 ( + + + {/* */} + Continue + + ); +} + +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 ( + + + + {props.tabs.length > 1 && ( + + {props.tabs.map((tab, index) => { + return ( +
setCurrentTab(index)} + > + {tab.title} +
+ ); + })} +
+ )} + {props.tabs.map((tab, index) => { + return ( + + ); + })} +
+
+
+ ); +} + +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 ( + + +

setOpen((prev) => !prev)} + > + {open ? : } + {props.iterationContext.summary || + props.iterationContext.codeSelections + .map((cs) => cs.filepath) + .join("\n")} +

+ + {open && ( + <> + + {props.iterationContext.action} + + {props.iterationContext.error && ( + + {props.iterationContext.error} + + )} + {props.iterationContext.suggestedChanges.map((sc) => { + return ( + + {sc.filepath} + {sc.replacement} + + ); + })} + + )} +
+
+ ); +} + +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(null); + + useEffect(() => { + if (isHovered) { + naturalLanguageInputRef.current?.focus(); + } + }, [isHovered]); + + const onTextInput = useCallback(() => { + if (naturalLanguageInputRef.current) { + props.onRefinement(naturalLanguageInputRef.current.value); + naturalLanguageInputRef.current.value = ""; + } + }, [naturalLanguageInputRef]); + + return ( + { + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + hidden={props.historyNode.step.hide as any} + > + setOpen((prev) => !prev)} + > + + +

+ {open ? ( + + ) : ( + + )} + {props.historyNode.step.name as any}: +

+ { + e.stopPropagation(); + props.onReverse(); + }} + > + + +
+ + + {props.historyNode.step.description as any} + + + {props.historyNode.step.name === "Waiting for user input" && ( + { + 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" && ( + <> + + { + props.onUserInput("ok"); + e.preventDefault(); + e.stopPropagation(); + }} + type="button" + value="Confirm" + /> + + )} + + {open && ( + <> + {/* {props.historyNode.observation && ( + + Error Here + + )} */} + {/* {props.iterationContext.suggestedChanges.map((sc) => { + return ( + + {sc.filepath} + {sc.replacement} + + ); + })} */} + + )} +
+
+ + +
+ ); +} + +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 ( + + {props.title} +

+ {props.children} +
+ ); +} + +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 ( + { + postVscMessage("openFile", { path: props.path }); + }} + > + {props.text || props.path} + + ); +} + +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 new file mode 100644 index 00000000..3b16ddf9 Binary files /dev/null and b/extension/react-app/src/continue_arrow.png differ 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(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(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( + + + , +) 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 ( +
+

Additional Context

+ +

+
+ ); +} + +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([]); + const isStreaming = useSelector(selectIsStreaming); + + useEffect(() => { + if (!isStreaming) { + hljs.highlightAll(); + } + }, [richContent, isStreaming]); + + useEffect(() => { + setRichContent([{props.content}]); + }, [props.content]); + + return ( + <> +
+ {richContent} +
+ + ); +} + +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, getResponse: () => Promise) => { + 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(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 ( + +
+

Chat

+
+
+ {chatMessages.length > 0 ? ( + chatMessages.map((message, idx) => { + return ; + }) + ) : ( +

+ You can ask questions about your codebase or ask for code written + directly in the editor. +

+ )} + {waitingForResponse && } +
+
+ + +
+
+ {/*

+ Highlighted code is automatically included in your chat message. +

*/} + { + setWriteToEditor(!writeToEditor); + }} + > + {writeToEditor ? "Writing to editor" : "Write to editor"} + + + { + setIncludeHighlightedCode(!includeHighlightedCode); + }} + > + {includeHighlightedCode + ? "Including highlighted code" + : "Automatically finding relevant code"} + +
+
+ { + 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); + } + }} + > +
+
+ ); +} + +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 ( +
+

Debug Panel

+ +

Code Sections

+ + +

Bug Description

+ + +

Stack Trace

+ + + + + + + + + + + + + + +

+
+ ); +} + +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([]); + const [history, setHistory] = useState(); + // { + // 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: "", + // 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 \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 \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: "", + // 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 \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(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 ( + + {history?.timeline.map((node: HistoryNode, index: number) => { + return ( + { + 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 && } + +
+ {userInputQueue.map((input) => { + return {input}; + })} +
+ + { + 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"; + }} + > + +
+ ); +} + +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 ( +
+

Welcome to Continue

+ +

+ Learn more in the{" "} + + Continue User Guide + {" "} +

+

Send Nate or Ty your feedback:

+

1. What excites you about Continue?

+

2. What did you struggle with when using Continue?

+

3. How do you wish Continue worked?

+
+ ); +} + +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(); + const [unittestApi, setUnittestApi] = useState(); + const [chatApi, setChatApi] = useState(); + + 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; + 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 { + 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 + ) { + 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 @@ +/// 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 { + 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(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) { + postVscMessage("withProgress", { title, done: false }); + return fn().finally(() => { + postVscMessage("withProgress", { title, done: true }); + }); +} -- cgit v1.2.3-70-g09d2