From c313ad4392abe76282db90a3e6df2623c24dd3d9 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 8 Oct 2024 09:29:55 -0700 Subject: [PATCH 1/7] Chat: preserve unsent chat messages when switching tabs --- lib/shared/src/chat/transcript/messages.ts | 3 + vscode/webviews/Chat.tsx | 5 ++ vscode/webviews/CodyPanel.tsx | 58 ++++++++++++++++++- vscode/webviews/chat/Transcript.tsx | 13 ++++- .../messageCell/human/HumanMessageCell.tsx | 6 +- 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/lib/shared/src/chat/transcript/messages.ts b/lib/shared/src/chat/transcript/messages.ts index 4bfcf58ccb1..3697885ae59 100644 --- a/lib/shared/src/chat/transcript/messages.ts +++ b/lib/shared/src/chat/transcript/messages.ts @@ -2,6 +2,7 @@ import type { ContextItem } from '../../codebase-context/messages' import type { Message } from '../../sourcegraph-api' import type { SerializedChatTranscript } from '.' +import type { SerializedPromptEditorValue } from '../..' /** * The list of context items (most important first) along with @@ -29,6 +30,8 @@ export interface ChatMessage extends Message { */ editorState?: unknown + lastStoredEditorValue?: SerializedPromptEditorValue + /** * The model used to generate this chat message response. Not set on human messages. */ diff --git a/vscode/webviews/Chat.tsx b/vscode/webviews/Chat.tsx index 8ed3e27b697..96c61416683 100644 --- a/vscode/webviews/Chat.tsx +++ b/vscode/webviews/Chat.tsx @@ -7,6 +7,7 @@ import type { CodyIDE, Guardrails, PromptString, + SerializedPromptEditorValue, } from '@sourcegraph/cody-shared' import { Transcript, focusLastHumanMessageEditor } from './chat/Transcript' import type { VSCodeWrapper } from './utils/VSCodeApi' @@ -32,6 +33,8 @@ interface ChatboxProps { showIDESnippetActions?: boolean setView: (view: View) => void smartApplyEnabled?: boolean + + updateEditorStateOnChange: (index: number, state: SerializedPromptEditorValue) => void } export const Chat: React.FunctionComponent> = ({ @@ -45,6 +48,7 @@ export const Chat: React.FunctionComponent showIDESnippetActions = true, setView, smartApplyEnabled, + updateEditorStateOnChange, }) => { const telemetryRecorder = useTelemetryRecorder() @@ -224,6 +228,7 @@ export const Chat: React.FunctionComponent postMessage={postMessage} guardrails={guardrails} smartApplyEnabled={smartApplyEnabled} + updateEditorStateOnChange={updateEditorStateOnChange} /> {transcript.length === 0 && showWelcomeMessage && ( <> diff --git a/vscode/webviews/CodyPanel.tsx b/vscode/webviews/CodyPanel.tsx index e8b488363ad..1133bfe94be 100644 --- a/vscode/webviews/CodyPanel.tsx +++ b/vscode/webviews/CodyPanel.tsx @@ -1,6 +1,19 @@ -import { type AuthStatus, type ClientCapabilities, CodyIDE } from '@sourcegraph/cody-shared' +import { + type AuthStatus, + type ClientCapabilities, + CodyIDE, + type SerializedPromptEditorValue, +} from '@sourcegraph/cody-shared' import type React from 'react' -import { type ComponentProps, type FunctionComponent, useMemo, useRef } from 'react' +import { + type ComponentProps, + type FunctionComponent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import type { ConfigurationSubsetForWebview, LocalEnv } from '../src/chat/protocol' import styles from './App.module.css' import { Chat } from './Chat' @@ -55,6 +68,44 @@ export const CodyPanel: FunctionComponent< }) => { const tabContainerRef = useRef(null) + const [transcriptState, setTranscriptState] = useState({ + current: transcript, + lastEditor: transcript, + }) + + // Update the lastEditor in transcriptState on every input box changes for chat message associated with the index. + const updateEditorStateOnChange = useCallback( + (index: number, newEditorValue: SerializedPromptEditorValue) => { + setTranscriptState(prev => { + const updated = [...prev.lastEditor] + updated[index] = { + ...updated[index], + lastStoredEditorValue: newEditorValue, + speaker: 'human', + } + return { ...prev, lastEditor: updated } + }) + }, + [] + ) + + // Reset transcripts on new transcript change. + // This ensures editor states are reset when switching to a new chat session. + useEffect(() => { + setTranscriptState({ current: transcript, lastEditor: transcript }) + }, [transcript]) + + // Set the current transcript to the transcript with the last stored editor states when switching to a different tab. + // This ensures the editor states are preserved when switching back to the chat tab. + useEffect(() => { + if (view !== View.Chat) { + setTranscriptState(prev => ({ + current: prev.lastEditor, + lastEditor: prev.lastEditor, + })) + } + }, [view]) + return ( ({ view, setView }), [view, setView])}> {view === View.Chat && ( void } export const Transcript: FC = props => { @@ -57,6 +59,7 @@ export const Transcript: FC = props => { insertButtonOnSubmit, smartApply, smartApplyEnabled, + updateEditorStateOnChange, } = props const interactions = useMemo( @@ -92,6 +95,7 @@ export const Transcript: FC = props => { )} smartApply={smartApply} smartApplyEnabled={smartApplyEnabled} + updateEditorStateOnChange={updateEditorStateOnChange} /> ))} @@ -160,6 +164,8 @@ interface TranscriptInteractionProps isLastInteraction: boolean isLastSentInteraction: boolean priorAssistantMessageIsLoading: boolean + + updateEditorStateOnChange: (index: number, state: SerializedPromptEditorValue) => void } const TranscriptInteraction: FC = memo(props => { @@ -178,6 +184,7 @@ const TranscriptInteraction: FC = memo(props => { copyButtonOnSubmit, smartApply, smartApplyEnabled, + updateEditorStateOnChange, } = props const [intentResults, setIntentResults] = useState< @@ -221,6 +228,10 @@ const TranscriptInteraction: FC = memo(props => { return debounce(async (editorValue: SerializedPromptEditorValue) => { setIntentResults(undefined) + if (editorValue.text) { + updateEditorStateOnChange(humanMessage.index, editorValue) + } + if (!experimentalOneBoxEnabled) { return } @@ -240,7 +251,7 @@ const TranscriptInteraction: FC = memo(props => { }) } }, 300) - }, [experimentalOneBoxEnabled, extensionAPI]) + }, [humanMessage.index, experimentalOneBoxEnabled, extensionAPI, updateEditorStateOnChange]) const onStop = useCallback(() => { getVSCodeAPI().postMessage({ diff --git a/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx b/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx index 3069d0da37d..963b765832f 100644 --- a/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx +++ b/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx @@ -68,8 +68,10 @@ export const HumanMessageCell: FunctionComponent<{ }) => { const messageJSON = JSON.stringify(message) const initialEditorState = useMemo( - () => serializedPromptEditorStateFromChatMessage(JSON.parse(messageJSON)), - [messageJSON] + () => + message.lastStoredEditorValue?.editorState ?? + serializedPromptEditorStateFromChatMessage(JSON.parse(messageJSON)), + [message.lastStoredEditorValue?.editorState, messageJSON] ) return ( From 2a84329df4565d4283e8061ffcf3c32c94eb5fce Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 8 Oct 2024 09:36:26 -0700 Subject: [PATCH 2/7] add new test case to Chat.story --- vscode/webviews/Chat.story.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/vscode/webviews/Chat.story.tsx b/vscode/webviews/Chat.story.tsx index 7d4d983e82c..c45f43d69dc 100644 --- a/vscode/webviews/Chat.story.tsx +++ b/vscode/webviews/Chat.story.tsx @@ -1,4 +1,4 @@ -import { ExtensionAPIProviderForTestsOnly, MOCK_API } from '@sourcegraph/prompt-editor' +import { ExtensionAPIProviderForTestsOnly, MOCK_API, SerializedPromptEditorValue } from '@sourcegraph/prompt-editor' import type { Meta, StoryObj } from '@storybook/react' import { Observable } from 'observable-fns' import { Chat } from './Chat' @@ -27,6 +27,7 @@ const meta: Meta = { onMessage: () => () => {}, }, setView: () => {}, + updateEditorStateOnChange: () => {}, } satisfies React.ComponentProps, decorators: [VSCodeWebview], @@ -76,3 +77,12 @@ export const EmptyWithNoPrompts: StoryObj = { } export const Disabled: StoryObj = { args: { chatEnabled: false } } + +export const UnsentMessagePreservation: StoryObj = { + args: { + transcript: [], + updateEditorStateOnChange: (index: number, state: SerializedPromptEditorValue) => { + console.log(`Editor state updated for message ${index}:`, state) + }, + }, +} From fdce59a014fb66ce4eda067db7aef3750d4f9102 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 8 Oct 2024 09:57:54 -0700 Subject: [PATCH 3/7] Update Transcript unit test and story --- vscode/webviews/chat/Transcript.story.tsx | 6 ++++ vscode/webviews/chat/Transcript.test.tsx | 41 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/vscode/webviews/chat/Transcript.story.tsx b/vscode/webviews/chat/Transcript.story.tsx index 9b36310769a..f116a9db0e2 100644 --- a/vscode/webviews/chat/Transcript.story.tsx +++ b/vscode/webviews/chat/Transcript.story.tsx @@ -7,6 +7,7 @@ import { type ChatMessage, PromptString, RateLimitError, + SerializedPromptEditorValue, errorToChatError, ps, } from '@sourcegraph/cody-shared' @@ -15,6 +16,10 @@ import type { ComponentProps } from 'react' import { URI } from 'vscode-uri' import { VSCodeWebview } from '../storybook/VSCodeStoryDecorator' +const updateEditorStateOnChange = (index: number, state: SerializedPromptEditorValue) => { + console.log(`Editor state updated for index ${index}:`, state) +} + const meta: Meta = { title: 'ui/Transcript', component: Transcript, @@ -36,6 +41,7 @@ const meta: Meta = { userInfo: FIXTURE_USER_ACCOUNT_INFO, postMessage: () => {}, chatEnabled: true, + updateEditorStateOnChange: () => {}, } satisfies ComponentProps, decorators: [ diff --git a/vscode/webviews/chat/Transcript.test.tsx b/vscode/webviews/chat/Transcript.test.tsx index fba56e4e588..5df0f24f93b 100644 --- a/vscode/webviews/chat/Transcript.test.tsx +++ b/vscode/webviews/chat/Transcript.test.tsx @@ -15,6 +15,7 @@ const PROPS: Omit, 'transcript'> = { userInfo: FIXTURE_USER_ACCOUNT_INFO, chatEnabled: true, postMessage: () => {}, + updateEditorStateOnChange: () => {}, } vi.mock('@vscode/webview-ui-toolkit/react', () => ({ @@ -398,4 +399,44 @@ describe('transcriptToInteractionPairs', () => { }, ]) }) + + test('preserves editor state when switching tabs', async () => { + const waitForDebounce = () => new Promise(resolve => setTimeout(resolve, 350)); // 350ms to be safe as we debounce on change. + + const humanMessage: ChatMessage = { + speaker: 'human', + text: ps`Foo`, + contextFiles: [], + } + const assistantMessage: ChatMessage = { speaker: 'assistant', text: ps`Bar` } + const updateEditorStateOnChange = vi.fn() + const { container, rerender } = render( + + ) + + const editor = container.querySelector( + '[role="row"]:last-child [data-lexical-editor="true"]' + )! as EditorHTMLElement + editor.textContent = 'Unsent message' + + await typeInEditor(editor, 'Unsent message updated') + + // Wait for the debounce + await waitForDebounce() + + rerender( + + ) + + expect(editor.textContent).toBe('Unsent message updated') + }) + }) From 36500c1bd3283033264c8b6ae73207deed0490b8 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 8 Oct 2024 10:00:26 -0700 Subject: [PATCH 4/7] fix lint --- vscode/webviews/Chat.story.tsx | 6 +++++- vscode/webviews/chat/Transcript.story.tsx | 2 +- vscode/webviews/chat/Transcript.test.tsx | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/vscode/webviews/Chat.story.tsx b/vscode/webviews/Chat.story.tsx index c45f43d69dc..7e0a2f4fd8a 100644 --- a/vscode/webviews/Chat.story.tsx +++ b/vscode/webviews/Chat.story.tsx @@ -1,4 +1,8 @@ -import { ExtensionAPIProviderForTestsOnly, MOCK_API, SerializedPromptEditorValue } from '@sourcegraph/prompt-editor' +import { + ExtensionAPIProviderForTestsOnly, + MOCK_API, + type SerializedPromptEditorValue, +} from '@sourcegraph/prompt-editor' import type { Meta, StoryObj } from '@storybook/react' import { Observable } from 'observable-fns' import { Chat } from './Chat' diff --git a/vscode/webviews/chat/Transcript.story.tsx b/vscode/webviews/chat/Transcript.story.tsx index f116a9db0e2..2d9d246bc06 100644 --- a/vscode/webviews/chat/Transcript.story.tsx +++ b/vscode/webviews/chat/Transcript.story.tsx @@ -7,7 +7,7 @@ import { type ChatMessage, PromptString, RateLimitError, - SerializedPromptEditorValue, + type SerializedPromptEditorValue, errorToChatError, ps, } from '@sourcegraph/cody-shared' diff --git a/vscode/webviews/chat/Transcript.test.tsx b/vscode/webviews/chat/Transcript.test.tsx index 5df0f24f93b..e7d481a2361 100644 --- a/vscode/webviews/chat/Transcript.test.tsx +++ b/vscode/webviews/chat/Transcript.test.tsx @@ -401,7 +401,7 @@ describe('transcriptToInteractionPairs', () => { }) test('preserves editor state when switching tabs', async () => { - const waitForDebounce = () => new Promise(resolve => setTimeout(resolve, 350)); // 350ms to be safe as we debounce on change. + const waitForDebounce = () => new Promise(resolve => setTimeout(resolve, 350)) // 350ms to be safe as we debounce on change. const humanMessage: ChatMessage = { speaker: 'human', @@ -438,5 +438,4 @@ describe('transcriptToInteractionPairs', () => { expect(editor.textContent).toBe('Unsent message updated') }) - }) From ff038cb51e9f2e0fd6f9feb9929ff3861e9042cc Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 8 Oct 2024 10:07:54 -0700 Subject: [PATCH 5/7] fix --- vscode/webviews/chat/Transcript.story.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/vscode/webviews/chat/Transcript.story.tsx b/vscode/webviews/chat/Transcript.story.tsx index 2d9d246bc06..d7a2a088598 100644 --- a/vscode/webviews/chat/Transcript.story.tsx +++ b/vscode/webviews/chat/Transcript.story.tsx @@ -7,7 +7,6 @@ import { type ChatMessage, PromptString, RateLimitError, - type SerializedPromptEditorValue, errorToChatError, ps, } from '@sourcegraph/cody-shared' @@ -16,10 +15,6 @@ import type { ComponentProps } from 'react' import { URI } from 'vscode-uri' import { VSCodeWebview } from '../storybook/VSCodeStoryDecorator' -const updateEditorStateOnChange = (index: number, state: SerializedPromptEditorValue) => { - console.log(`Editor state updated for index ${index}:`, state) -} - const meta: Meta = { title: 'ui/Transcript', component: Transcript, From b527447ca19e41adfe6e2a95e72ec56a9d86e299 Mon Sep 17 00:00:00 2001 From: Beatrix Date: Tue, 8 Oct 2024 18:08:55 -0700 Subject: [PATCH 6/7] update active states --- vscode/webviews/CodyPanel.tsx | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/vscode/webviews/CodyPanel.tsx b/vscode/webviews/CodyPanel.tsx index 1133bfe94be..9220d589505 100644 --- a/vscode/webviews/CodyPanel.tsx +++ b/vscode/webviews/CodyPanel.tsx @@ -1,5 +1,6 @@ import { type AuthStatus, + type ChatMessage, type ClientCapabilities, CodyIDE, type SerializedPromptEditorValue, @@ -68,43 +69,38 @@ export const CodyPanel: FunctionComponent< }) => { const tabContainerRef = useRef(null) - const [transcriptState, setTranscriptState] = useState({ - current: transcript, - lastEditor: transcript, - }) + const [activeTranscript, setActiveTranscript] = useState(undefined) + const [storedTranscriptState, setStoredTranscriptState] = useState(transcript) // Update the lastEditor in transcriptState on every input box changes for chat message associated with the index. const updateEditorStateOnChange = useCallback( (index: number, newEditorValue: SerializedPromptEditorValue) => { - setTranscriptState(prev => { - const updated = [...prev.lastEditor] + setStoredTranscriptState(prev => { + const updated = [...prev] updated[index] = { ...updated[index], lastStoredEditorValue: newEditorValue, speaker: 'human', } - return { ...prev, lastEditor: updated } + return updated }) }, [] ) // Reset transcripts on new transcript change. - // This ensures editor states are reset when switching to a new chat session. useEffect(() => { - setTranscriptState({ current: transcript, lastEditor: transcript }) + setActiveTranscript(undefined) + setStoredTranscriptState(transcript) }, [transcript]) // Set the current transcript to the transcript with the last stored editor states when switching to a different tab. // This ensures the editor states are preserved when switching back to the chat tab. useEffect(() => { if (view !== View.Chat) { - setTranscriptState(prev => ({ - current: prev.lastEditor, - lastEditor: prev.lastEditor, - })) + setActiveTranscript(storedTranscriptState) } - }, [view]) + }, [view, storedTranscriptState]) return ( ({ view, setView }), [view, setView])}> @@ -127,7 +123,7 @@ export const CodyPanel: FunctionComponent< {view === View.Chat && ( Date: Tue, 8 Oct 2024 18:41:35 -0700 Subject: [PATCH 7/7] remove lastStoredEditorValue --- lib/shared/src/chat/transcript/messages.ts | 3 --- vscode/webviews/Chat.tsx | 1 - vscode/webviews/CodyPanel.tsx | 6 +++--- .../chat/cells/messageCell/human/HumanMessageCell.tsx | 6 ++---- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/lib/shared/src/chat/transcript/messages.ts b/lib/shared/src/chat/transcript/messages.ts index 3697885ae59..4bfcf58ccb1 100644 --- a/lib/shared/src/chat/transcript/messages.ts +++ b/lib/shared/src/chat/transcript/messages.ts @@ -2,7 +2,6 @@ import type { ContextItem } from '../../codebase-context/messages' import type { Message } from '../../sourcegraph-api' import type { SerializedChatTranscript } from '.' -import type { SerializedPromptEditorValue } from '../..' /** * The list of context items (most important first) along with @@ -30,8 +29,6 @@ export interface ChatMessage extends Message { */ editorState?: unknown - lastStoredEditorValue?: SerializedPromptEditorValue - /** * The model used to generate this chat message response. Not set on human messages. */ diff --git a/vscode/webviews/Chat.tsx b/vscode/webviews/Chat.tsx index 96c61416683..dd82456d3ea 100644 --- a/vscode/webviews/Chat.tsx +++ b/vscode/webviews/Chat.tsx @@ -33,7 +33,6 @@ interface ChatboxProps { showIDESnippetActions?: boolean setView: (view: View) => void smartApplyEnabled?: boolean - updateEditorStateOnChange: (index: number, state: SerializedPromptEditorValue) => void } diff --git a/vscode/webviews/CodyPanel.tsx b/vscode/webviews/CodyPanel.tsx index 9220d589505..398df63dd7b 100644 --- a/vscode/webviews/CodyPanel.tsx +++ b/vscode/webviews/CodyPanel.tsx @@ -69,17 +69,17 @@ export const CodyPanel: FunctionComponent< }) => { const tabContainerRef = useRef(null) - const [activeTranscript, setActiveTranscript] = useState(undefined) + const [activeTranscript, setActiveTranscript] = useState(transcript) const [storedTranscriptState, setStoredTranscriptState] = useState(transcript) - // Update the lastEditor in transcriptState on every input box changes for chat message associated with the index. + // Update the Transcript State for each input box value change. const updateEditorStateOnChange = useCallback( (index: number, newEditorValue: SerializedPromptEditorValue) => { setStoredTranscriptState(prev => { const updated = [...prev] updated[index] = { ...updated[index], - lastStoredEditorValue: newEditorValue, + editorState: newEditorValue.editorState, speaker: 'human', } return updated diff --git a/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx b/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx index 963b765832f..3069d0da37d 100644 --- a/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx +++ b/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx @@ -68,10 +68,8 @@ export const HumanMessageCell: FunctionComponent<{ }) => { const messageJSON = JSON.stringify(message) const initialEditorState = useMemo( - () => - message.lastStoredEditorValue?.editorState ?? - serializedPromptEditorStateFromChatMessage(JSON.parse(messageJSON)), - [message.lastStoredEditorValue?.editorState, messageJSON] + () => serializedPromptEditorStateFromChatMessage(JSON.parse(messageJSON)), + [messageJSON] ) return (