mirror of
https://github.com/voideditor/void
synced 2026-05-24 09:58:23 +00:00
Merge pull request #53 from anetaj/feature/persist-threads-history
Persist chat threads history
This commit is contained in:
commit
e04d839640
14 changed files with 338 additions and 125 deletions
2
extensions/void/.vscode/settings.json
vendored
2
extensions/void/.vscode/settings.json
vendored
|
|
@ -8,7 +8,7 @@
|
||||||
"**/.DS_Store": true,
|
"**/.DS_Store": true,
|
||||||
"**/Thumbs.db": true,
|
"**/Thumbs.db": true,
|
||||||
"out": false,
|
"out": false,
|
||||||
"**/node_modules": false
|
"**/node_modules": true
|
||||||
},
|
},
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"out": true // set this to false to include "out" folder in search results
|
"out": true // set this to false to include "out" folder in search results
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,6 @@ Here's an overview on how the extension works:
|
||||||
- The extension mounts in `extension.ts`.
|
- The extension mounts in `extension.ts`.
|
||||||
|
|
||||||
- The Sidebar's HTML (everything in `sidebar/`) is built in React, and it's rendered by mounting a `<script>` tag - see `SidebarWebviewProvider.ts`.
|
- The Sidebar's HTML (everything in `sidebar/`) is built in React, and it's rendered by mounting a `<script>` tag - see `SidebarWebviewProvider.ts`.
|
||||||
|
|
||||||
|
- Communication between the sidebar script and the extension takes place via API. You can search for "postMessage" to see where API calls happen.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,15 @@
|
||||||
"title": "Discard Diff"
|
"title": "Discard Diff"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"command": "void.startNewThread",
|
||||||
|
"title": "Start a new chat",
|
||||||
|
"icon": "$(add)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "void.toggleThreadSelector",
|
||||||
|
"title": "View past chats",
|
||||||
|
"icon": "$(history)"
|
||||||
|
}, {
|
||||||
"command": "void.openSettings",
|
"command": "void.openSettings",
|
||||||
"title": "Void settings",
|
"title": "Void settings",
|
||||||
"icon": "$(settings-gear)"
|
"icon": "$(settings-gear)"
|
||||||
|
|
@ -265,6 +274,16 @@
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
"view/title": [
|
"view/title": [
|
||||||
|
{
|
||||||
|
"command": "void.startNewThread",
|
||||||
|
"when": "view == 'void.viewnumberone'",
|
||||||
|
"group": "navigation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "void.toggleThreadSelector",
|
||||||
|
"when": "view == 'void.viewnumberone'",
|
||||||
|
"group": "navigation"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "void.openSettings",
|
"command": "void.openSettings",
|
||||||
"when": "view == 'void.viewnumberone'",
|
"when": "view == 'void.viewnumberone'",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import Anthropic from '@anthropic-ai/sdk';
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { Ollama } from 'ollama/browser'
|
import { Ollama } from 'ollama/browser'
|
||||||
import { getVSCodeAPI } from '../sidebar/getVscodeApi';
|
|
||||||
|
|
||||||
|
|
||||||
// always compare these against package.json to make sure every setting in this type can actually be provided by the user
|
// always compare these against package.json to make sure every setting in this type can actually be provided by the user
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { WebviewMessage } from './shared_types';
|
import { ChatThreads, WebviewMessage } from './shared_types';
|
||||||
import { CtrlKCodeLensProvider } from './CtrlKCodeLensProvider';
|
import { CtrlKCodeLensProvider } from './CtrlKCodeLensProvider';
|
||||||
import { getDiffedLines } from './getDiffedLines';
|
import { getDiffedLines } from './getDiffedLines';
|
||||||
import { ApprovalCodeLensProvider } from './ApprovalCodeLensProvider';
|
import { ApprovalCodeLensProvider } from './ApprovalCodeLensProvider';
|
||||||
|
|
@ -100,6 +100,14 @@ export function activate(context: vscode.ExtensionContext) {
|
||||||
webviewProvider.webview.then(
|
webviewProvider.webview.then(
|
||||||
webview => {
|
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
|
// when config changes, send it to the sidebar
|
||||||
vscode.workspace.onDidChangeConfiguration(e => {
|
vscode.workspace.onDidChangeConfiguration(e => {
|
||||||
if (e.affectsConfiguration('void')) {
|
if (e.affectsConfiguration('void')) {
|
||||||
|
|
@ -135,6 +143,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||||
await approvalCodeLensProvider.addNewApprovals(editor, suggestedEdits)
|
await approvalCodeLensProvider.addNewApprovals(editor, suggestedEdits)
|
||||||
}
|
}
|
||||||
else if (m.type === 'getApiConfig') {
|
else if (m.type === 'getApiConfig') {
|
||||||
|
context.workspaceState.update('allThreads', {})
|
||||||
|
|
||||||
const apiConfig = getApiConfig()
|
const apiConfig = getApiConfig()
|
||||||
console.log('Api config:', apiConfig)
|
console.log('Api config:', apiConfig)
|
||||||
|
|
@ -142,6 +151,15 @@ export function activate(context: vscode.ExtensionContext) {
|
||||||
webview.postMessage({ type: 'apiConfig', apiConfig } satisfies WebviewMessage)
|
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 {
|
else {
|
||||||
console.error('unrecognized command', m.type, m)
|
console.error('unrecognized command', m.type, m)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,52 @@ type WebviewMessage = (
|
||||||
// editor -> sidebar
|
// editor -> sidebar
|
||||||
| { type: 'apiConfig', apiConfig: ApiConfig }
|
| { 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 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: Selection | 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 {
|
export {
|
||||||
Selection,
|
Selection,
|
||||||
File,
|
File,
|
||||||
WebviewMessage,
|
WebviewMessage,
|
||||||
Command,
|
Command,
|
||||||
|
ChatThreads,
|
||||||
|
ChatMessage,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect, useRef, useCallback, FormEvent } from "react"
|
import React, { useState, useEffect, useRef, useCallback, FormEvent } from "react"
|
||||||
import { ApiConfig, sendLLMMessage } from "../common/sendLLMMessage"
|
import { ApiConfig, sendLLMMessage } from "../common/sendLLMMessage"
|
||||||
import { File, Selection, WebviewMessage } from "../shared_types"
|
import { ChatMessage, File, Selection, WebviewMessage } from "../shared_types"
|
||||||
import { awaitVSCodeResponse, getVSCodeAPI, resolveAwaitingVSCodeResponse } from "./getVscodeApi"
|
import { awaitVSCodeResponse, getVSCodeAPI, resolveAwaitingVSCodeResponse } from "./getVscodeApi"
|
||||||
|
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
@ -8,7 +8,8 @@ import MarkdownRender from "./markdown/MarkdownRender";
|
||||||
import BlockCode from "./markdown/BlockCode";
|
import BlockCode from "./markdown/BlockCode";
|
||||||
|
|
||||||
import * as vscode from 'vscode'
|
import * as vscode from 'vscode'
|
||||||
import { FilesSelector, IncludedFiles } from "./components/Files";
|
import { FilesSelector, SelectedFiles } from "./components/SelectedFiles";
|
||||||
|
import { useChat } from "./chatContext";
|
||||||
|
|
||||||
|
|
||||||
const filesStr = (fullFiles: File[]) => {
|
const filesStr = (fullFiles: File[]) => {
|
||||||
|
|
@ -49,7 +50,7 @@ const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => {
|
||||||
|
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
chatbubbleContents = <>
|
chatbubbleContents = <>
|
||||||
<IncludedFiles files={chatMessage.files} />
|
<SelectedFiles files={chatMessage.files} />
|
||||||
{chatMessage.selection?.selectionStr && <BlockCode text={chatMessage.selection.selectionStr} hideToolbar />}
|
{chatMessage.selection?.selectionStr && <BlockCode text={chatMessage.selection.selectionStr} hideToolbar />}
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
|
|
@ -67,34 +68,48 @@ const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessage = {
|
const ThreadSelector = ({ onClose }: { onClose: () => void }) => {
|
||||||
role: 'user'
|
const { allThreads, currentThread, switchToThread } = useChat()
|
||||||
content: string, // content sent to the llm
|
return (
|
||||||
displayContent: string, // content displayed to user
|
<div className="flex flex-col space-y-1">
|
||||||
selection: Selection | null, // the user's selection
|
<div className="text-right">
|
||||||
files: vscode.Uri[], // the files sent in the message
|
<button className="btn btn-sm" onClick={onClose}>
|
||||||
} | {
|
<svg
|
||||||
role: 'assistant',
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
content: string, // content received from LLM
|
fill="none"
|
||||||
displayContent: string // content displayed to user (this is the same as content for now)
|
viewBox="0 0 24 24"
|
||||||
}
|
stroke="currentColor"
|
||||||
|
className="size-4"
|
||||||
|
>
|
||||||
// const [stateRef, setState] = useInstantState(initVal)
|
<path
|
||||||
// setState instantly changes the value of stateRef instead of having to wait until the next render
|
strokeLinecap="round"
|
||||||
const useInstantState = <T,>(initVal: T) => {
|
strokeLinejoin="round"
|
||||||
const stateRef = useRef<T>(initVal)
|
d="M6 18 18 6M6 6l12 12"
|
||||||
const [_, setS] = useState<T>(initVal)
|
/>
|
||||||
const setState = useCallback((newVal: T) => {
|
</svg>
|
||||||
setS(newVal);
|
</button>
|
||||||
stateRef.current = newVal;
|
</div>
|
||||||
}, [])
|
{/* iterate through all past threads */}
|
||||||
return [stateRef as React.RefObject<T>, setState] as const // make s.current readonly - setState handles all changes
|
{Object.keys(allThreads ?? {}).map((threadId) => {
|
||||||
|
const pastThread = (allThreads ?? {})[threadId];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pastThread.id}
|
||||||
|
className={`btn btn-sm btn-secondary ${pastThread.id === currentThread?.id ? "btn-primary" : ""}`}
|
||||||
|
onClick={() => switchToThread(pastThread.id)}
|
||||||
|
>
|
||||||
|
{new Date(pastThread.createdAt).toLocaleString()}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
|
const { allThreads, currentThread, addMessageToHistory, startNewThread, } = useChat()
|
||||||
|
|
||||||
// state of current message
|
// state of current message
|
||||||
const [selection, setSelection] = useState<Selection | null>(null) // the code the user is selecting
|
const [selection, setSelection] = useState<Selection | null>(null) // the code the user is selecting
|
||||||
|
|
@ -102,9 +117,9 @@ const Sidebar = () => {
|
||||||
const [instructions, setInstructions] = useState('') // the user's instructions
|
const [instructions, setInstructions] = useState('') // the user's instructions
|
||||||
|
|
||||||
// state of chat
|
// state of chat
|
||||||
const [chatMessageHistory, setChatMessageHistory] = useState<ChatMessage[]>([])
|
|
||||||
const [messageStream, setMessageStream] = useState('')
|
const [messageStream, setMessageStream] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isThreadSelectorOpen, setIsThreadSelectorOpen] = useState(false)
|
||||||
|
|
||||||
const abortFnRef = useRef<(() => void) | null>(null)
|
const abortFnRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
|
@ -126,13 +141,12 @@ const Sidebar = () => {
|
||||||
|
|
||||||
// if user pressed ctrl+l, add their selection to the sidebar
|
// if user pressed ctrl+l, add their selection to the sidebar
|
||||||
if (m.type === 'ctrl+l') {
|
if (m.type === 'ctrl+l') {
|
||||||
|
|
||||||
setSelection(m.selection)
|
setSelection(m.selection)
|
||||||
|
|
||||||
const filepath = m.selection.filePath
|
const filepath = m.selection.filePath
|
||||||
|
|
||||||
// add file if it's not a duplicate
|
// 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])
|
if (!files.find(f => f.fsPath === filepath.fsPath))
|
||||||
|
setFiles(files => [...files, filepath])
|
||||||
|
|
||||||
}
|
}
|
||||||
// when get apiConfig, set
|
// when get apiConfig, set
|
||||||
|
|
@ -140,10 +154,21 @@ const Sidebar = () => {
|
||||||
setApiConfig(m.apiConfig)
|
setApiConfig(m.apiConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if they pressed the + to add a new chat
|
||||||
|
else if (m.type === 'startNewThread') {
|
||||||
|
setIsThreadSelectorOpen(false)
|
||||||
|
startNewThread()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if they opened thread selector
|
||||||
|
else if (m.type === 'toggleThreadSelector') {
|
||||||
|
setIsThreadSelectorOpen(v => !v)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
window.addEventListener('message', listener);
|
window.addEventListener('message', listener);
|
||||||
return () => { window.removeEventListener('message', listener) }
|
return () => { window.removeEventListener('message', listener) }
|
||||||
}, [files, selection])
|
}, [files, selection, startNewThread])
|
||||||
|
|
||||||
|
|
||||||
const formRef = useRef<HTMLFormElement | null>(null)
|
const formRef = useRef<HTMLFormElement | null>(null)
|
||||||
|
|
@ -158,15 +183,6 @@ const Sidebar = () => {
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
setFiles([])
|
setFiles([])
|
||||||
|
|
||||||
|
|
||||||
// TODO this is just a hack, turn this into a button instead, and track all histories somewhere
|
|
||||||
if (instructions === 'clear') {
|
|
||||||
setChatMessageHistory([])
|
|
||||||
setMessageStream('')
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// request file content from vscode and await response
|
// request file content from vscode and await response
|
||||||
getVSCodeAPI().postMessage({ type: 'requestFiles', filepaths: files })
|
getVSCodeAPI().postMessage({ type: 'requestFiles', filepaths: files })
|
||||||
const relevantFiles = await awaitVSCodeResponse('files')
|
const relevantFiles = await awaitVSCodeResponse('files')
|
||||||
|
|
@ -175,16 +191,18 @@ const Sidebar = () => {
|
||||||
const content = userInstructionsStr(instructions, relevantFiles.files, selection)
|
const content = userInstructionsStr(instructions, relevantFiles.files, selection)
|
||||||
// console.log('prompt:\n', content)
|
// console.log('prompt:\n', content)
|
||||||
const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files }
|
const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files }
|
||||||
setChatMessageHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt])
|
addMessageToHistory(newHistoryElt)
|
||||||
|
|
||||||
// send message to claude
|
// send message to claude
|
||||||
let { abort } = sendLLMMessage({
|
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),
|
onText: (newText, fullText) => setMessageStream(fullText),
|
||||||
onFinalMessage: (content) => {
|
onFinalMessage: (content) => {
|
||||||
// add assistant's message to chat history, and clear selection
|
// add assistant's message to chat history, and clear selection
|
||||||
const newHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content, }
|
const newHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content, }
|
||||||
setChatMessageHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt])
|
addMessageToHistory(newHistoryElt)
|
||||||
|
|
||||||
|
// clear selection
|
||||||
setMessageStream('')
|
setMessageStream('')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
},
|
},
|
||||||
|
|
@ -201,12 +219,12 @@ const Sidebar = () => {
|
||||||
// if messageStream was not empty, add it to the history
|
// if messageStream was not empty, add it to the history
|
||||||
const llmContent = messageStream || '(canceled)'
|
const llmContent = messageStream || '(canceled)'
|
||||||
const newHistoryElt: ChatMessage = { role: 'assistant', displayContent: messageStream, content: llmContent }
|
const newHistoryElt: ChatMessage = { role: 'assistant', displayContent: messageStream, content: llmContent }
|
||||||
setChatMessageHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt])
|
addMessageToHistory(newHistoryElt)
|
||||||
|
|
||||||
setMessageStream('')
|
setMessageStream('')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|
||||||
}, [messageStream])
|
}, [addMessageToHistory, messageStream])
|
||||||
|
|
||||||
//Clear code selection
|
//Clear code selection
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
|
|
@ -215,9 +233,14 @@ const Sidebar = () => {
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<div className="flex flex-col h-screen w-full">
|
<div className="flex flex-col h-screen w-full">
|
||||||
|
{isThreadSelectorOpen && (
|
||||||
|
<div className="mb-2 max-h-[30vh] overflow-y-auto">
|
||||||
|
<ThreadSelector onClose={() => setIsThreadSelectorOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="overflow-y-auto overflow-x-hidden space-y-4">
|
<div className="overflow-y-auto overflow-x-hidden space-y-4">
|
||||||
{/* previous messages */}
|
{/* previous messages */}
|
||||||
{chatMessageHistory.map((message, i) =>
|
{currentThread !== null && currentThread.messages.map((message, i) =>
|
||||||
<ChatBubble key={i} chatMessage={message} />
|
<ChatBubble key={i} chatMessage={message} />
|
||||||
)}
|
)}
|
||||||
{/* message stream */}
|
{/* message stream */}
|
||||||
|
|
@ -225,61 +248,71 @@ const Sidebar = () => {
|
||||||
</div>
|
</div>
|
||||||
{/* chatbar */}
|
{/* chatbar */}
|
||||||
<div className="shrink-0 py-4">
|
<div className="shrink-0 py-4">
|
||||||
<div className="input">
|
{/* selection */}
|
||||||
{/* selection */}
|
<div className="text-left">
|
||||||
{(files.length || selection?.selectionStr) && <div className="p-2 pb-0 space-y-2">
|
{/* selected files */}
|
||||||
{/* selected files */}
|
<FilesSelector files={files} setFiles={setFiles} />
|
||||||
<FilesSelector files={files} setFiles={setFiles} />
|
{/* selected code */}
|
||||||
{/* selected code */}
|
|
||||||
{!!selection?.selectionStr && (
|
|
||||||
<BlockCode className="rounded bg-vscode-sidebar-bg" text={selection.selectionStr} toolbar={(
|
|
||||||
<button
|
|
||||||
onClick={clearSelection}
|
|
||||||
className="btn btn-secondary btn-sm border border-vscode-input-border rounded"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
</div>}
|
|
||||||
<form
|
|
||||||
ref={formRef}
|
|
||||||
className="flex flex-row items-center rounded-md p-2"
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) onSubmit(e) }}
|
|
||||||
|
|
||||||
onSubmit={(e) => {
|
|
||||||
console.log('submit!')
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(e)
|
|
||||||
}}>
|
|
||||||
{/* input */}
|
|
||||||
|
|
||||||
<textarea
|
<div className="relative">
|
||||||
onChange={(e) => { setInstructions(e.target.value) }}
|
<div className="input">
|
||||||
className="w-full p-2 leading-tight resize-none max-h-[50vh] overflow-hidden bg-transparent border-none !outline-none"
|
{/* selection */}
|
||||||
placeholder="Ctrl+L to select"
|
{(files.length || selection?.selectionStr) && <div className="p-2 pb-0 space-y-2">
|
||||||
rows={1}
|
{/* selected files */}
|
||||||
onInput={e => { e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px' }} // Adjust height dynamically
|
<FilesSelector files={files} setFiles={setFiles} />
|
||||||
/>
|
{/* selected code */}
|
||||||
{/* submit button */}
|
{!!selection?.selectionStr && (
|
||||||
{isLoading ?
|
<BlockCode className="rounded bg-vscode-sidebar-bg" text={selection.selectionStr} toolbar={(
|
||||||
<button
|
<button
|
||||||
onClick={onStop}
|
onClick={clearSelection}
|
||||||
className="btn btn-primary rounded-r-lg max-h-10 p-2"
|
className="btn btn-secondary btn-sm border border-vscode-input-border rounded"
|
||||||
type='button'
|
>
|
||||||
>Stop</button>
|
Remove
|
||||||
: <button
|
</button>
|
||||||
className="btn btn-primary font-bold size-8 flex justify-center items-center rounded-full p-2 max-h-10"
|
)} />
|
||||||
disabled={!instructions}
|
)}
|
||||||
type='submit'
|
</div>}
|
||||||
>
|
<form
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
ref={formRef}
|
||||||
<line x1="12" y1="19" x2="12" y2="5"></line>
|
className="flex flex-row items-center rounded-md p-2"
|
||||||
<polyline points="5 12 12 5 19 12"></polyline>
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) onSubmit(e) }}
|
||||||
</svg>
|
|
||||||
</button>
|
onSubmit={(e) => {
|
||||||
}
|
console.log('submit!')
|
||||||
</form>
|
e.preventDefault();
|
||||||
|
onSubmit(e)
|
||||||
|
}}>
|
||||||
|
{/* input */}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
onChange={(e) => { setInstructions(e.target.value) }}
|
||||||
|
className="w-full p-2 leading-tight resize-none max-h-[50vh] overflow-hidden bg-transparent border-none !outline-none"
|
||||||
|
placeholder="Ctrl+L to select"
|
||||||
|
rows={1}
|
||||||
|
onInput={e => { e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px' }} // Adjust height dynamically
|
||||||
|
/>
|
||||||
|
{/* submit button */}
|
||||||
|
{isLoading ?
|
||||||
|
<button
|
||||||
|
onClick={onStop}
|
||||||
|
className="btn btn-primary rounded-r-lg max-h-10 p-2"
|
||||||
|
type='button'
|
||||||
|
>Stop</button>
|
||||||
|
: <button
|
||||||
|
className="btn btn-primary font-bold size-8 flex justify-center items-center rounded-full p-2 max-h-10"
|
||||||
|
disabled={!instructions}
|
||||||
|
type='submit'
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="12" y1="19" x2="12" y2="5"></line>
|
||||||
|
<polyline points="5 12 12 5 19 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
102
extensions/void/src/sidebar/chatContext.tsx
Normal file
102
extensions/void/src/sidebar/chatContext.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React, { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState, } from "react"
|
||||||
|
import { ChatMessage, ChatThreads } from "../shared_types"
|
||||||
|
import { awaitVSCodeResponse, getVSCodeAPI } from "./getVscodeApi"
|
||||||
|
|
||||||
|
|
||||||
|
type ChatContextValue = {
|
||||||
|
readonly allThreads: ChatThreads | null,
|
||||||
|
readonly currentThread: ChatThreads[string] | null;
|
||||||
|
addMessageToHistory: (message: ChatMessage) => void;
|
||||||
|
switchToThread: (threadId: string) => void;
|
||||||
|
startNewThread: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatContext = createContext<ChatContextValue>({} as ChatContextValue)
|
||||||
|
|
||||||
|
const createNewThread = () => ({
|
||||||
|
id: new Date().getTime().toString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
messages: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// const [stateRef, setState] = useInstantState(initVal)
|
||||||
|
// setState instantly changes the value of stateRef instead of having to wait until the next render
|
||||||
|
const useInstantState = <T,>(initVal: T) => {
|
||||||
|
const stateRef = useRef<T>(initVal)
|
||||||
|
const [_, setS] = useState<T>(initVal)
|
||||||
|
const setState = useCallback((newVal: T) => {
|
||||||
|
setS(newVal);
|
||||||
|
stateRef.current = newVal;
|
||||||
|
}, [])
|
||||||
|
return [stateRef as React.RefObject<T>, setState] as const // make s.current readonly - setState handles all changes
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function ChatProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [allThreads, setAllThreads] = useInstantState<ChatThreads>({})
|
||||||
|
const [currentThreadId, setCurrentThreadId] = useInstantState<string | null>(null)
|
||||||
|
|
||||||
|
// this loads allThreads in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
getVSCodeAPI().postMessage({ type: "getAllThreads" })
|
||||||
|
awaitVSCodeResponse('allThreads')
|
||||||
|
.then(response => { setAllThreads(response.threads) })
|
||||||
|
}, [setAllThreads])
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatContext.Provider
|
||||||
|
value={{
|
||||||
|
allThreads: allThreads.current,
|
||||||
|
currentThread: currentThreadId.current === null || allThreads.current === null ? null : allThreads.current[currentThreadId.current],
|
||||||
|
addMessageToHistory: (message: ChatMessage) => {
|
||||||
|
let currentThread: ChatThreads[string]
|
||||||
|
if (!(currentThreadId.current === null || allThreads.current === null)) {
|
||||||
|
currentThread = allThreads.current[currentThreadId.current]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
currentThread = createNewThread()
|
||||||
|
setCurrentThreadId(currentThread.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('adding message: ', currentThreadId, currentThread.id, message.displayContent)
|
||||||
|
console.log('allThreads', allThreads)
|
||||||
|
|
||||||
|
setAllThreads({
|
||||||
|
...allThreads.current,
|
||||||
|
[currentThread.id]: {
|
||||||
|
...currentThread,
|
||||||
|
messages: [...currentThread.messages, message],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
getVSCodeAPI().postMessage({ type: "persistThread", thread: currentThread })
|
||||||
|
},
|
||||||
|
switchToThread: (threadId: string) => {
|
||||||
|
setCurrentThreadId(threadId);
|
||||||
|
},
|
||||||
|
startNewThread: () => {
|
||||||
|
const newThread = createNewThread()
|
||||||
|
setAllThreads({
|
||||||
|
...allThreads.current,
|
||||||
|
[newThread.id]: newThread
|
||||||
|
})
|
||||||
|
setCurrentThreadId(newThread.id)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ChatContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useChat(): ChatContextValue {
|
||||||
|
const context = useContext<ChatContextValue>(ChatContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useChat must be used within a ChatProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ChatProvider, useChat }
|
||||||
|
|
@ -51,7 +51,7 @@ export const FilesSelector = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IncludedFiles = ({ files }: { files: vscode.Uri[] }) => {
|
export const SelectedFiles = ({ files }: { files: vscode.Uri[] }) => {
|
||||||
return (
|
return (
|
||||||
files.length !== 0 && (
|
files.length !== 0 && (
|
||||||
<div className="text-xs my-2">
|
<div className="text-xs my-2">
|
||||||
|
|
@ -10,6 +10,11 @@ const awaiting: { [c in Command]: ((res: any) => void)[] } = {
|
||||||
"files": [],
|
"files": [],
|
||||||
"apiConfig": [],
|
"apiConfig": [],
|
||||||
"getApiConfig": [],
|
"getApiConfig": [],
|
||||||
|
"startNewThread": [],
|
||||||
|
"getAllThreads": [],
|
||||||
|
"allThreads": [],
|
||||||
|
"persistThread": [],
|
||||||
|
"toggleThreadSelector": []
|
||||||
}
|
}
|
||||||
|
|
||||||
// use this function to await responses
|
// use this function to await responses
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as ReactDOM from 'react-dom/client'
|
import * as ReactDOM from "react-dom/client"
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from "./Sidebar"
|
||||||
|
import { ChatProvider } from "./chatContext"
|
||||||
|
|
||||||
// mount the sidebar on the id="root" element
|
// mount the sidebar on the id="root" element
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === "undefined") {
|
||||||
console.log('index.tsx error: document was undefined')
|
console.log("index.tsx error: document was undefined")
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootElement = document.getElementById('root')!
|
const rootElement = document.getElementById("root")!
|
||||||
console.log('root Element', rootElement)
|
console.log("root Element", rootElement)
|
||||||
|
|
||||||
|
const extension = (
|
||||||
|
<ChatProvider>
|
||||||
|
<Sidebar />
|
||||||
|
</ChatProvider>
|
||||||
|
)
|
||||||
const root = ReactDOM.createRoot(rootElement)
|
const root = ReactDOM.createRoot(rootElement)
|
||||||
root.render(<Sidebar />)
|
root.render(extension)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { ReactNode, useCallback, useEffect, useState } from "react"
|
import React, { ReactNode, useCallback, useEffect, useState } from "react"
|
||||||
import { getVSCodeAPI } from "../getVscodeApi"
|
import { getVSCodeAPI } from "../getVscodeApi"
|
||||||
import { classNames } from "../utils"
|
|
||||||
|
|
||||||
enum CopyButtonState {
|
enum CopyButtonState {
|
||||||
Copy = "Copy",
|
Copy = "Copy",
|
||||||
|
|
@ -70,11 +69,7 @@ const BlockCode = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={`overflow-x-auto rounded-sm text-vscode-editor-fg bg-vscode-editor-bg ${!hideToolbar ? "rounded-tl-none" : ""} ${className}`}
|
||||||
"overflow-x-auto rounded-sm text-vscode-editor-fg bg-vscode-editor-bg",
|
|
||||||
!hideToolbar && "rounded-tl-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<pre className="p-2">{text}</pre>
|
<pre className="p-2">{text}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function classNames(...classes: any[]) {
|
|
||||||
return classes.filter(Boolean).join(' ')
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as classNames } from "./classNames";
|
|
||||||
Loading…
Reference in a new issue