diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 546ff24a..e1622143 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Welcome! 👋 This is a guide on how to contribute to Void. We want to make it a There are two main ways to contribute: -- Suggest New Features ([discord](https://discord.gg/4GAxHVAD)) +- Suggest New Features ([discord](https://discord.gg/RSNjgaugJs)) - Build New Features ([project](https://github.com/orgs/voideditor/projects/2/views/3)) We use a [VSCode extension](https://code.visualstudio.com/api/get-started/your-first-extension) to implement most of Void's functionality. Scroll down to see 1. How to build/contribute to the Extension, or 2. How to build/contribute to the full IDE (for more native changes). @@ -50,7 +50,7 @@ Now that you're set up, feel free to check out our [Issues](https://github.com/v Beyond the extension, we very occasionally edit the IDE when we need to access more functionality. If you want to work on the full IDE, please follow the steps below, or see VS Code's full [how to contribute](https://github.com/microsoft/vscode/wiki/How-to-Contribute) page. -Before starting, make sure you've built the extension (by running `cd .\extensions\void\` and `npm run build`). +Before starting, make sure you've built the extension (by running `cd .\extensions\void\` and `npm run build`). Also make sure you have Python on your system. Make sure you're on the correct NodeJS version as per `.nvmrc`. @@ -124,8 +124,21 @@ Please don't make big refactors without speaking with us first. We'd like to kee # Submitting a Pull Request -When you've made changes and want to submit them, please submit a pull request. +Please submit a pull request once you've made a change. Here are a few guidelines: +- A PR should be about one *single* feature change. The fewer items you change, the more likely the PR is to be accepted. + +- Your PR should contain a description that first explains at a high level what you did, and then describes the exact changes you made (and to which files). Please don't use vague statements like "refactored code" or "improved types" (instead, describe what code you refactored, or what types you changed). + +- Your title should clearly describe the change you made. + +- Add tags to help us stay organized! + +- Please don't open a new Issue for your PR. Just submit the PR. + +- Avoid refactoring and making feature changes in the same PR. + +- Write good code. For example, a common mistake when people edit Void's config is to hard-code a default value like `'claude-3.5'` in 2+ separate places. Please follow best practices or describe your thought process if you had to compromise. # Relevant files diff --git a/extensions/void/.vscode/settings.json b/extensions/void/.vscode/settings.json index 3e6c33c2..3100dfe2 100644 --- a/extensions/void/.vscode/settings.json +++ b/extensions/void/.vscode/settings.json @@ -8,7 +8,7 @@ "**/.DS_Store": true, "**/Thumbs.db": true, "out": false, - "**/node_modules": false + "**/node_modules": true }, "search.exclude": { "out": true // set this to false to include "out" folder in search results diff --git a/extensions/void/README.md b/extensions/void/README.md index 04d86826..d455ba30 100644 --- a/extensions/void/README.md +++ b/extensions/void/README.md @@ -1 +1,11 @@ Please see the `CONTRIBUTING.md` for information on how to contribute :)! + + +Here's an overview on how the extension works: + +- The extension mounts in `extension.ts`. + +- The Sidebar's HTML (everything in `sidebar/`) is built in React, and it's rendered by mounting a ` + + `; + + webview.html = webviewHTML; + } + + // called internally by vscode resolveWebviewView( webviewView: vscode.WebviewView, @@ -36,43 +89,17 @@ export class SidebarWebviewProvider implements vscode.WebviewViewProvider { token: vscode.CancellationToken, ) { - const webview = webviewView.webview + const webview = webviewView.webview; webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] }; - // This allows us to use React in vscode - // when you run `npm run build`, we take the React code in the `sidebar` folder - // and compile it into `dist/sidebar/index.js` and `dist/sidebar/styles.css` - // we render that code here - const rootPath = this._extensionUri; - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(rootPath, 'dist/sidebar/index.js')); - const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(rootPath, 'dist/sidebar/styles.css')); - const rootUri = webview.asWebviewUri(vscode.Uri.joinPath(rootPath)); - - const nonce = getNonce(); // only scripts with the nonce are allowed to run, this is a recommended security measure - - - const allowed_urls = ['https://api.anthropic.com', 'https://api.openai.com', 'https://api.greptile.com'] - webview.html = ` - - - - - Custom View - - - - - -
- - - `; - + this.updateWebviewHTML(webview); + // resolve webview and _webviewView this._res(webview); + this._webviewView = webviewView; } } diff --git a/extensions/void/src/common/sendLLMMessage.ts b/extensions/void/src/common/sendLLMMessage.ts index 9e47a80a..c7313ea8 100644 --- a/extensions/void/src/common/sendLLMMessage.ts +++ b/extensions/void/src/common/sendLLMMessage.ts @@ -1,16 +1,18 @@ import Anthropic from '@anthropic-ai/sdk'; import OpenAI from 'openai'; +import { Ollama } from 'ollama/browser' -// import ollama from 'ollama' +// always compare these against package.json to make sure every setting in this type can actually be provided by the user export type ApiConfig = { anthropic: { apikey: string, model: string, maxTokens: string }, - openai: { - apikey: string + openAI: { + apikey: string, + model: string, }, greptile: { apikey: string, @@ -22,8 +24,14 @@ export type ApiConfig = { } }, ollama: { - // TODO + endpoint: string, + model: string }, + openAICompatible: { + endpoint: string, + model: string, + apikey: string + } whichApi: string } @@ -100,31 +108,44 @@ const sendClaudeMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal -// OpenAI +// OpenAI and OpenAICompatible const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { - let did_abort = false + let didAbort = false let fullText = '' // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - let abort: () => void = () => { did_abort = true } + let abort: () => void = () => { + didAbort = true; + }; - const openai = new OpenAI({ apiKey: apiConfig.openai.apikey, dangerouslyAllowBrowser: true }); + let openai: OpenAI + let options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming - openai.chat.completions.create({ - model: 'gpt-4o-2024-08-06', - messages: messages, - stream: true, - }) + if (apiConfig.whichApi === 'openAI') { + openai = new OpenAI({ baseURL: apiConfig.openAICompatible.endpoint, apiKey: apiConfig.openAICompatible.apikey, dangerouslyAllowBrowser: true }) + options = { model: apiConfig.openAI.model, messages: messages, stream: true, } + } + else if (apiConfig.whichApi === 'openAICompatible') { + openai = new OpenAI({ apiKey: apiConfig.openAI.apikey, dangerouslyAllowBrowser: true }); + options = { model: apiConfig.openAICompatible.model, messages: messages, stream: true, } + } + else { + console.error(`sendOpenAIMsg: invalid whichApi: ${apiConfig.whichApi}`) + throw new Error(`apiConfig.whichAPI was invalid: ${apiConfig.whichApi}`) + } + + openai.chat.completions + .create(options) .then(async response => { abort = () => { - // response.controller.abort() // this isn't needed now, to keep consistency with claude will leave it commented - did_abort = true; + // response.controller.abort() + didAbort = true; } // when receive text try { for await (const chunk of response) { - if (did_abort) return; + if (didAbort) return; const newText = chunk.choices[0]?.delta?.content || ''; fullText += newText; onText(newText, fullText); @@ -136,8 +157,49 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal console.error('Error in OpenAI stream:', error); onFinalMessage(fullText); } - // when we get the final message on this stream - onFinalMessage(fullText) + }) + return { abort }; +}; + + +// Ollama +export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { + + let didAbort = false + let fullText = "" + + // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either + let abort = () => { + didAbort = true; + }; + + const ollama = new Ollama({ host: apiConfig.ollama.endpoint }) + + ollama.chat({ + model: apiConfig.ollama.model, + messages: messages, + stream: true, + }) + .then(async stream => { + abort = () => { + // ollama.abort() + didAbort = true + } + // iterate through the stream + try { + for await (const chunk of stream) { + if (didAbort) return; + const newText = chunk.message.content; + fullText += newText; + onText(newText, fullText); + } + onFinalMessage(fullText); + } + // when error/fail + catch (error) { + console.error('Error:', error); + onFinalMessage(fullText); + } }) return { abort }; }; @@ -150,11 +212,11 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { - let did_abort = false + let didAbort = false let fullText = '' // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - let abort: () => void = () => { did_abort = true } + let abort: () => void = () => { didAbort = true } fetch('https://api.greptile.com/v2/query', { @@ -178,7 +240,7 @@ const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFin }) // TODO make this actually stream, right now it just sends one message at the end .then(async responseArr => { - if (did_abort) + if (didAbort) return for (let response of responseArr) { @@ -213,74 +275,26 @@ const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFin return { abort } - - } + export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, apiConfig }) => { if (!apiConfig) return { abort: () => { } } - const whichApi = apiConfig.whichApi - - if (whichApi === 'anthropic') { - return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }) + switch (apiConfig.whichApi) { + case 'anthropic': + return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); + case 'openAI': + case 'openAICompatible': + return sendOpenAIMsg({ messages, onText, onFinalMessage, apiConfig }); + case 'greptile': + return sendGreptileMsg({ messages, onText, onFinalMessage, apiConfig }); + case 'ollama': + return sendOllamaMsg({ messages, onText, onFinalMessage, apiConfig }); + default: + console.error(`Error: whichApi was ${apiConfig.whichApi}, which is not recognized!`); + return { abort: () => { } } + //return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); // TODO } - else if (whichApi === 'openai') { - return sendOpenAIMsg({ messages, onText, onFinalMessage, apiConfig }) - } - else if (whichApi === 'greptile') { - return sendGreptileMsg({ messages, onText, onFinalMessage, apiConfig }) - } - else if (whichApi === 'ollama') { - return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }) // TODO - } - else { - console.error(`Error: whichApi was ${whichApi}, which is not recognized!`) - return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }) // TODO - } - } - - -// Ollama -// const sendOllamaMsg: sendMsgFnType = ({ messages, onText, onFinalMessage }) => { - -// let did_abort = false -// let fullText = '' - -// // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either -// let abort: () => void = () => { -// did_abort = true -// } - -// ollama.chat({ model: 'llama3.1', messages: messages, stream: true }) -// .then(async response => { - -// abort = () => { -// // response.abort() // this isn't needed now, to keep consistency with claude will leave it commented for now -// did_abort = true; -// } - -// // when receive text -// try { -// for await (const part of response) { -// if (did_abort) return -// let newText = part.message.content -// fullText += newText -// onText(newText, fullText) -// } -// } -// // when error/fail -// catch (e) { -// onFinalMessage(fullText) -// return -// } - -// // when we get the final message on this stream -// onFinalMessage(fullText) -// }) - -// return { abort }; -// }; - diff --git a/extensions/void/src/extension.ts b/extensions/void/src/extension.ts index 2a89a9e9..ef224417 100644 --- a/extensions/void/src/extension.ts +++ b/extensions/void/src/extension.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode'; -import { BaseDiffArea, WebviewMessage } from './shared_types'; -import { CtrlKCodeLensProvider } from './CtrlKCodeLensProvider'; import { DisplayChangesProvider } from './DisplayChangesProvider'; +import { BaseDiffArea, ChatThreads, WebviewMessage } from './shared_types'; import { SidebarWebviewProvider } from './SidebarWebviewProvider'; import { ApiConfig } from './common/sendLLMMessage'; @@ -14,14 +13,17 @@ const readFileContentOfUri = async (uri: vscode.Uri) => { const getApiConfig = () => { const apiConfig: ApiConfig = { anthropic: { - apikey: vscode.workspace.getConfiguration('void').get('anthropicApiKey') ?? '', - model: vscode.workspace.getConfiguration('void').get('anthropicModel') ?? '', - maxTokens: vscode.workspace.getConfiguration('void').get('anthropicMaxToken') ?? '', + apikey: vscode.workspace.getConfiguration('void.anthropic').get('apiKey') ?? '', + model: vscode.workspace.getConfiguration('void.anthropic').get('model') ?? '', + maxTokens: vscode.workspace.getConfiguration('void.anthropic').get('maxTokens') ?? '', + }, + openAI: { + apikey: vscode.workspace.getConfiguration('void.openAI').get('apiKey') ?? '', + model: vscode.workspace.getConfiguration('void.openAI').get('model') ?? '', }, - openai: { apikey: vscode.workspace.getConfiguration('void').get('openAIApiKey') ?? '' }, greptile: { - apikey: vscode.workspace.getConfiguration('void').get('greptileApiKey') ?? '', - githubPAT: vscode.workspace.getConfiguration('void').get('githubPAT') ?? '', + apikey: vscode.workspace.getConfiguration('void.greptile').get('apiKey') ?? '', + githubPAT: vscode.workspace.getConfiguration('void.greptile').get('githubPAT') ?? '', repoinfo: { remote: 'github', repository: 'TODO', @@ -29,13 +31,20 @@ const getApiConfig = () => { } }, ollama: { - // apikey: vscode.workspace.getConfiguration('void').get('ollamaSettings') ?? '', + endpoint: vscode.workspace.getConfiguration('void.ollama').get('endpoint') ?? '', + model: vscode.workspace.getConfiguration('void.ollama').get('model') ?? '', + }, + openAICompatible: { + endpoint: vscode.workspace.getConfiguration('void.openAICompatible').get('endpoint') ?? '', + apikey: vscode.workspace.getConfiguration('void.openAICompatible').get('apiKey') ?? '', + model: vscode.workspace.getConfiguration('void.openAICompatible').get('model') ?? '', }, whichApi: vscode.workspace.getConfiguration('void').get('whichApi') ?? '' } return apiConfig } + export function activate(context: vscode.ExtensionContext) { // 1. Mount the chat sidebar @@ -90,6 +99,14 @@ export function activate(context: vscode.ExtensionContext) { webviewProvider.webview.then( webview => { + // top navigation bar commands + context.subscriptions.push(vscode.commands.registerCommand('void.startNewThread', async () => { + webview.postMessage({ type: 'startNewThread' } satisfies WebviewMessage) + })) + context.subscriptions.push(vscode.commands.registerCommand('void.toggleThreadSelector', async () => { + webview.postMessage({ type: 'toggleThreadSelector' } satisfies WebviewMessage) + })) + // when config changes, send it to the sidebar vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('void')) { @@ -114,8 +131,6 @@ export function activate(context: vscode.ExtensionContext) { } else if (m.type === 'applyChanges') { - console.log('Applying changes') - const editor = vscode.window.activeTextEditor if (!editor) { vscode.window.showInformationMessage('No active editor!') @@ -148,15 +163,19 @@ export function activate(context: vscode.ExtensionContext) { } else if (m.type === 'getApiConfig') { - const apiConfig = getApiConfig() - console.log('Api config:', apiConfig) - webview.postMessage({ type: 'apiConfig', apiConfig } satisfies WebviewMessage) - + } + else if (m.type === 'getAllThreads') { + const threads: ChatThreads = context.workspaceState.get('allThreads') ?? {} + webview.postMessage({ type: 'allThreads', threads } satisfies WebviewMessage) + } + else if (m.type === 'persistThread') { + const threads: ChatThreads = context.workspaceState.get('allThreads') ?? {} + const updatedThreads: ChatThreads = { ...threads, [m.thread.id]: m.thread } + context.workspaceState.update('allThreads', updatedThreads) } else { - console.error('unrecognized command', m.type, m) } }) diff --git a/extensions/void/src/shared_types.ts b/extensions/void/src/shared_types.ts index 25843aaa..ad2025f1 100644 --- a/extensions/void/src/shared_types.ts +++ b/extensions/void/src/shared_types.ts @@ -58,16 +58,55 @@ type WebviewMessage = ( // editor -> sidebar | { type: 'apiConfig', apiConfig: ApiConfig } + // sidebar -> editor + | { type: 'getAllThreads' } + + // editor -> sidebar + | { type: 'allThreads', threads: ChatThreads } + + // sidebar -> editor + | { type: 'persistThread', thread: ChatThreads[string] } + + // editor -> sidebar + | { type: 'startNewThread' } + + // editor -> sidebar + | { type: 'toggleThreadSelector' } + ) type Command = WebviewMessage['type'] +type ChatThreads = { + [id: string]: { + id: string; // store the id here too + createdAt: string; + messages: ChatMessage[]; + } +} + +type ChatMessage = + | { + role: "user"; + content: string; // content sent to the llm + displayContent: string; // content displayed to user + selection: CodeSelection | null; // the user's selection + files: vscode.Uri[]; // the files sent in the message + } + | { + role: "assistant"; + content: string; // content received from LLM + displayContent: string; // content displayed to user (this is the same as content for now) + } + export { BaseDiff, BaseDiffArea, + Diff, DiffArea, CodeSelection, File, WebviewMessage, Command, - Diff, DiffArea, + ChatThreads, + ChatMessage, } diff --git a/extensions/void/src/sidebar/MarkdownRender.tsx b/extensions/void/src/sidebar/MarkdownRender.tsx deleted file mode 100644 index ebc9a2a7..00000000 --- a/extensions/void/src/sidebar/MarkdownRender.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { JSX, useState } from 'react'; -import { MarkedToken, Token, TokensList } from 'marked'; -import { awaitVSCodeResponse, getVSCodeAPI } from './getVscodeApi'; - - -// code block with Apply button at top -export const BlockCode = ({ text, disableApplyButton = false }: { text: string, disableApplyButton?: boolean }) => { - return
- {disableApplyButton ? null :
- -
} -
-
-				{text}
-			
-
-
-} - -const Render = ({ token }: { token: Token }) => { - - // deal with built-in tokens first (assume marked token) - const t = token as MarkedToken - - if (t.type === "space") { - return {t.raw}; - } - - if (t.type === "code") { - return - } - - if (t.type === "heading") { - const HeadingTag = `h${t.depth}` as keyof JSX.IntrinsicElements; - return {t.text}; - } - - if (t.type === "table") { - return ( - - - - {t.header.map((cell: any, index: number) => ( - - ))} - - - - {t.rows.map((row: any[], rowIndex: number) => ( - - {row.map((cell: any, cellIndex: number) => ( - - ))} - - ))} - -
- {cell.raw} -
- {cell.raw} -
- ); - } - - if (t.type === "hr") { - return
; - } - - if (t.type === "blockquote") { - return
{t.text}
; - } - - if (t.type === "list") { - - const ListTag = t.ordered ? 'ol' : 'ul'; - return ( - - {t.items.map((item, index) => ( -
  • - {item.task && ( - - )} - {item.text} -
  • - ))} -
    - ); - } - - if (t.type === "paragraph") { - return

    - {t.tokens.map((token, index) => ( - - ))} -

    ; - } - - if (t.type === "html") { - return
    {``}{t.raw}{``}
    ; - } - - if (t.type === "text" || t.type === "escape") { - return {t.raw}; - } - - if (t.type === "def") { - return null; // Definitions are typically not rendered - } - - if (t.type === "link") { - return {t.text}; - } - - if (t.type === "image") { - return {t.text}; - } - - if (t.type === "strong") { - return {t.text}; - } - - if (t.type === "em") { - return {t.text}; - } - - // inline code - if (t.type === "codespan") { - return {t.text}; - } - - if (t.type === "br") { - return
    ; - } - - if (t.type === "del") { - return {t.text}; - } - - - // default - return
    - Unknown type: - {t.raw} -
    ; -}; - -const MarkdownRender = ({ tokens }: { tokens: TokensList }) => { - return ( - <> - {tokens.map((token, index) => ( - - ))} - - ); -}; - -export default MarkdownRender; diff --git a/extensions/void/src/sidebar/Sidebar.tsx b/extensions/void/src/sidebar/Sidebar.tsx index 82e633c1..8521d83d 100644 --- a/extensions/void/src/sidebar/Sidebar.tsx +++ b/extensions/void/src/sidebar/Sidebar.tsx @@ -1,12 +1,15 @@ -import React, { useState, ChangeEvent, useEffect, useRef, useCallback, FormEvent } from "react" -import { ApiConfig, LLMMessage, sendLLMMessage } from "../common/sendLLMMessage" -import { Command, File, CodeSelection, WebviewMessage } from "../shared_types" +import React, { useState, useEffect, useRef, useCallback, FormEvent } from "react" +import { ApiConfig, sendLLMMessage } from "../common/sendLLMMessage" +import { File, CodeSelection, WebviewMessage, ChatMessage } from "../shared_types" import { awaitVSCodeResponse, getVSCodeAPI, resolveAwaitingVSCodeResponse } from "./getVscodeApi" import { marked } from 'marked'; -import MarkdownRender, { BlockCode } from "./MarkdownRender"; +import MarkdownRender from "./markdown/MarkdownRender"; +import BlockCode from "./markdown/BlockCode"; import * as vscode from 'vscode' +import { SelectedFiles } from "./components/SelectedFiles"; +import { useThreads } from "./threadsContext"; const filesStr = (fullFiles: File[]) => { @@ -27,7 +30,7 @@ I am currently selecting this code: \`\`\`${selection.selectionStr}\`\`\` `} -Please edit the code following these instructions: +Please edit the code following these instructions (or, if appropriate, answer my question instead): ${instructions} If you make a change, rewrite the entire file. @@ -35,40 +38,6 @@ If you make a change, rewrite the entire file. } -const FilesSelector = ({ files, setFiles }: { files: vscode.Uri[], setFiles: (files: vscode.Uri[]) => void }) => { - return files.length !== 0 &&
    - Include files: - {files.map((filename, i) => -
    - {/* X button on a file */} - -
    - )} -
    -} - -const IncludedFiles = ({ files }: { files: vscode.Uri[] }) => { - return files.length !== 0 &&
    - {files.map((filename, i) => -
    - -
    - )} -
    -} - - const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => { const role = chatMessage.role @@ -81,14 +50,14 @@ const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => { if (role === 'user') { chatbubbleContents = <> - - {chatMessage.selection?.selectionStr && } + + {chatMessage.selection?.selectionStr && } {children} } else if (role === 'assistant') { - const tokens = marked.lexer(children); // https://marked.js.org/using_pro#renderer - chatbubbleContents = // sectionsHTML + + chatbubbleContents = // sectionsHTML } @@ -99,41 +68,48 @@ const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => { } -const getBasename = (pathStr: string) => { - // "unixify" path - pathStr = pathStr.replace(/[/\\]+/g, '/'); // replace any / or \ or \\ with / - const parts = pathStr.split('/') // split on / - return parts[parts.length - 1] -} - -type ChatMessage = { - role: 'user' - content: string, // content sent to the llm - displayContent: string, // content displayed to user - selection: CodeSelection | null, // the user's selection - files: vscode.Uri[], // the files sent in the message -} | { - role: 'assistant', - content: string, // content received from LLM - displayContent: string // content displayed to user (this is the same as content for now) -} - - -// const [stateRef, setState] = useInstantState(initVal) -// setState instantly changes the value of stateRef instead of having to wait until the next render -const useInstantState = (initVal: T) => { - const stateRef = useRef(initVal) - const [_, setS] = useState(initVal) - const setState = useCallback((newVal: T) => { - setS(newVal); - stateRef.current = newVal; - }, []) - return [stateRef as React.RefObject, setState] as const // make s.current readonly - setState handles all changes +const ThreadSelector = ({ onClose }: { onClose: () => void }) => { + const { allThreads, currentThread, switchToThread } = useThreads() + return ( +
    +
    + +
    + {/* iterate through all past threads */} + {Object.keys(allThreads ?? {}).map((threadId) => { + const pastThread = (allThreads ?? {})[threadId]; + return ( + + ) + })} +
    + ) } const Sidebar = () => { + const { allThreads, currentThread, addMessageToHistory, startNewThread, } = useThreads() // state of current message const [selection, setSelection] = useState(null) // the code the user is selecting @@ -141,9 +117,9 @@ const Sidebar = () => { const [instructions, setInstructions] = useState('') // the user's instructions // state of chat - const [chatMessageHistory, setChatHistory] = useState([]) const [messageStream, setMessageStream] = useState('') const [isLoading, setIsLoading] = useState(false) + const [isThreadSelectorOpen, setIsThreadSelectorOpen] = useState(false) const abortFnRef = useRef<(() => void) | null>(null) @@ -165,13 +141,12 @@ const Sidebar = () => { // if user pressed ctrl+l, add their selection to the sidebar if (m.type === 'ctrl+l') { - setSelection(m.selection) - const filepath = m.selection.filePath - // add file if it's not a duplicate - if (!files.find(f => f.fsPath === filepath.fsPath)) setFiles(files => [...files, filepath]) + // add current file to the context if it's not already in the files array + if (!files.find(f => f.fsPath === filepath.fsPath)) + setFiles(files => [...files, filepath]) } // when get apiConfig, set @@ -179,10 +154,22 @@ const Sidebar = () => { setApiConfig(m.apiConfig) } + // if they pressed the + to add a new chat + else if (m.type === 'startNewThread') { + setIsThreadSelectorOpen(false) + if (currentThread?.messages.length !== 0) + startNewThread() + } + + // if they opened thread selector + else if (m.type === 'toggleThreadSelector') { + setIsThreadSelectorOpen(v => !v) + } + } window.addEventListener('message', listener); return () => { window.removeEventListener('message', listener) } - }, [files, selection]) + }, [files, selection, startNewThread, currentThread]) const formRef = useRef(null) @@ -205,17 +192,16 @@ const Sidebar = () => { const content = userInstructionsStr(instructions, relevantFiles.files, selection) // console.log('prompt:\n', content) const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files } - setChatHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt]) + addMessageToHistory(newHistoryElt) // send message to LLM let { abort } = sendLLMMessage({ - messages: [...chatMessageHistory.map(m => ({ role: m.role, content: m.content })), { role: 'user', content }], + messages: [...(currentThread?.messages ?? []).map(m => ({ role: m.role, content: m.content })), { role: 'user', content }], onText: (newText, fullText) => setMessageStream(fullText), onFinalMessage: (content) => { - - // add assistant's message to chat history + // add assistant's message to chat history, and clear selection const newHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content, } - setChatHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt]) + addMessageToHistory(newHistoryElt) // clear selection setMessageStream('') @@ -234,12 +220,12 @@ const Sidebar = () => { // if messageStream was not empty, add it to the history const llmContent = messageStream || '(canceled)' const newHistoryElt: ChatMessage = { role: 'assistant', displayContent: messageStream, content: llmContent } - setChatHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt]) + addMessageToHistory(newHistoryElt) setMessageStream('') setIsLoading(false) - }, [messageStream]) + }, [addMessageToHistory, messageStream]) //Clear code selection const clearSelection = () => { @@ -248,9 +234,14 @@ const Sidebar = () => { return <>
    + {isThreadSelectorOpen && ( +
    + setIsThreadSelectorOpen(false)} /> +
    + )}
    {/* previous messages */} - {chatMessageHistory.map((message, i) => + {currentThread !== null && currentThread.messages.map((message, i) => )} {/* message stream */} @@ -260,60 +251,66 @@ const Sidebar = () => {
    {/* selection */}
    - {/* selected files */} - - {/* selected code */} - {!selection?.selectionStr ? null - : ( -
    - - -
    - )} + +
    +
    + {/* selection */} + {(files.length || selection?.selectionStr) &&
    + {/* selected files */} + + {/* selected code */} + {!!selection?.selectionStr && ( + + Remove + + )} /> + )} +
    } +
    { if (e.key === 'Enter' && !e.shiftKey) onSubmit(e) }} + + onSubmit={(e) => { + console.log('submit!') + e.preventDefault(); + onSubmit(e) + }}> + {/* input */} + +