Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pass chat history to webview using webviewAPI, not old postMessage protocol #5741

Merged
merged 1 commit into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
pass chat history to webview using webviewAPI, not old postMessage pr…
…otocol
  • Loading branch information
sqs committed Sep 30, 2024
commit 53869de8fb0a6151cfb1b7ad6bbb005bd0e0b1f0
5 changes: 4 additions & 1 deletion agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1232,7 +1232,10 @@ export class Agent extends MessageHandler implements ExtensionClient {
modelID ??= (await firstResultFromOperation(modelsService.getDefaultChatModel())) ?? ''
const chatMessages = messages?.map(PromptString.unsafe_deserializeChatMessage) ?? []
const chatBuilder = new ChatBuilder(modelID, chatID, chatMessages)
await chatHistory.saveChat(authStatus, chatBuilder.toSerializedChatTranscript())
const chat = chatBuilder.toSerializedChatTranscript()
if (chat) {
await chatHistory.saveChat(authStatus, chat)
}
return this.createChatPanel(
Promise.resolve({
type: 'chat',
Expand Down
8 changes: 7 additions & 1 deletion lib/shared/src/misc/rpc/webviewAPI.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Observable } from 'observable-fns'
import type { AuthStatus, ResolvedConfiguration } from '../..'
import type { ChatMessage } from '../../chat/transcript/messages'
import type { ChatMessage, UserLocalHistory } from '../../chat/transcript/messages'
import type { ContextItem } from '../../codebase-context/messages'
import type { CodyCommand } from '../../commands/types'
import type { FeatureFlag } from '../../experimentation/FeatureFlagProvider'
Expand Down Expand Up @@ -62,6 +62,11 @@ export interface WebviewToExtensionAPI {
* Observe the current transcript.
*/
transcript(): Observable<readonly ChatMessage[]>

/**
* The current user's chat history.
*/
userHistory(): Observable<UserLocalHistory | null>
}

export function createExtensionAPI(
Expand All @@ -84,6 +89,7 @@ export function createExtensionAPI(
resolvedConfig: proxyExtensionAPI(messageAPI, 'resolvedConfig'),
authStatus: proxyExtensionAPI(messageAPI, 'authStatus'),
transcript: proxyExtensionAPI(messageAPI, 'transcript'),
userHistory: proxyExtensionAPI(messageAPI, 'userHistory'),
}
}

Expand Down
21 changes: 4 additions & 17 deletions vscode/src/chat/chat-view/ChatController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,6 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
})
)
)

// Observe any changes in chat history and send client notifications to
// the consumer
this.disposables.push(
chatHistory.onHistoryChanged(chatHistory => {
this.postMessage({ type: 'history', localHistory: chatHistory })
})
)
}

/**
Expand Down Expand Up @@ -1466,15 +1458,9 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
const authStatus = currentAuthStatus()
if (authStatus.authenticated) {
// Only try to save if authenticated because otherwise we wouldn't be showing a chat.
const allHistory = await chatHistory.saveChat(
authStatus,
this.chatBuilder.toSerializedChatTranscript()
)
if (allHistory) {
void this.postMessage({
type: 'history',
localHistory: allHistory,
})
const chat = this.chatBuilder.toSerializedChatTranscript()
if (chat) {
await chatHistory.saveChat(authStatus, chat)
}
}
}
Expand Down Expand Up @@ -1674,6 +1660,7 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
authStatus: () => authStatus,
transcript: () =>
this.chatBuilder.changes.pipe(map(chat => chat.getDehydratedMessages())),
userHistory: () => chatHistory.changes,
}
)
)
Expand Down
73 changes: 45 additions & 28 deletions vscode/src/chat/chat-view/ChatHistoryManager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type {
AccountKeyedChatHistory,
AuthStatus,
AuthenticatedAuthStatus,
SerializedChatTranscript,
UserLocalHistory,
import {
type AccountKeyedChatHistory,
type AuthStatus,
type AuthenticatedAuthStatus,
type SerializedChatTranscript,
type UnauthenticatedAuthStatus,
type UserLocalHistory,
authStatus,
combineLatest,
distinctUntilChanged,
startWith,
} from '@sourcegraph/cody-shared'

import debounce from 'lodash/debounce'
import { type Observable, Subject, map } from 'observable-fns'
import * as vscode from 'vscode'
import { localStorage } from '../../services/LocalStorageProvider'

Expand All @@ -24,7 +28,9 @@ class ChatHistoryManager implements vscode.Disposable {
}
}

public getLocalHistory(authStatus: AuthenticatedAuthStatus): UserLocalHistory | null {
public getLocalHistory(
authStatus: Pick<AuthenticatedAuthStatus, 'endpoint' | 'username'>
): UserLocalHistory | null {
return localStorage.getChatHistory(authStatus)
}

Expand All @@ -38,16 +44,12 @@ class ChatHistoryManager implements vscode.Disposable {

public async saveChat(
authStatus: AuthenticatedAuthStatus,
chat: SerializedChatTranscript | undefined
): Promise<UserLocalHistory> {
chat: SerializedChatTranscript
): Promise<void> {
const history = localStorage.getChatHistory(authStatus)
if (chat === undefined) {
return history
}
history.chat[chat.id] = chat
await localStorage.setChatHistory(authStatus, history)
this.notifyChatHistoryChanged(authStatus)
return history
this.changeNotifications.next()
}

public async importChatHistory(
Expand All @@ -56,29 +58,44 @@ class ChatHistoryManager implements vscode.Disposable {
authStatus: AuthStatus
): Promise<void> {
await localStorage.importChatHistory(history, merge)
this.notifyChatHistoryChanged(authStatus)
this.changeNotifications.next()
}

public async deleteChat(authStatus: AuthenticatedAuthStatus, chatID: string): Promise<void> {
await localStorage.deleteChatHistory(authStatus, chatID)
this.notifyChatHistoryChanged(authStatus)
this.changeNotifications.next()
}

// Remove chat history and input history
public async clear(authStatus: AuthenticatedAuthStatus): Promise<void> {
await localStorage.removeChatHistory(authStatus)
this.notifyChatHistoryChanged(authStatus)
}

public onHistoryChanged(listener: (chatHistory: UserLocalHistory | null) => any): vscode.Disposable {
return this.historyChanged.event(listener)
this.changeNotifications.next()
}

private notifyChatHistoryChanged = debounce(
authStatus => this.historyChanged.fire(this.getLocalHistory(authStatus)),
100,
{ leading: true, trailing: true }
)
private changeNotifications = new Subject<void>()
public changes: Observable<UserLocalHistory | null> = combineLatest([
authStatus.pipe(
// Only need to rere-fetch the chat history when the endpoint or username changes for
// authed users (i.e., when they switch to a different account), not when anything else
// in the authStatus might change.
map(
(
authStatus
):
| UnauthenticatedAuthStatus
| Pick<AuthenticatedAuthStatus, 'authenticated' | 'endpoint' | 'username'> =>
authStatus.authenticated
? {
authenticated: authStatus.authenticated,
endpoint: authStatus.endpoint,
username: authStatus.username,
}
: authStatus
),
distinctUntilChanged()
),
this.changeNotifications.pipe(startWith(undefined)),
]).pipe(map(([authStatus]) => (authStatus.authenticated ? this.getLocalHistory(authStatus) : null)))
}

export const chatHistory = new ChatHistoryManager()
2 changes: 0 additions & 2 deletions vscode/src/chat/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type {
RequestMessage,
ResponseMessage,
SerializedChatMessage,
UserLocalHistory,
} from '@sourcegraph/cody-shared'

import type { BillingCategory, BillingProduct } from '@sourcegraph/cody-shared/src/telemetry-v2'
Expand Down Expand Up @@ -146,7 +145,6 @@ export type ExtensionMessage =
isDotComUser: boolean
workspaceFolderUris: string[]
}
| { type: 'history'; localHistory?: UserLocalHistory | undefined | null }
| {
/** Used by JetBrains and not VS Code. */
type: 'ui/theme'
Expand Down
10 changes: 7 additions & 3 deletions vscode/src/services/LocalStorageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,16 @@ class LocalStorage implements LocalStorageForModelPreferences {
await this.set(this.CODY_ENDPOINT_HISTORY, [...historySet], fire)
}

public getChatHistory(authStatus: AuthenticatedAuthStatus): UserLocalHistory {
public getChatHistory(
authStatus: Pick<AuthenticatedAuthStatus, 'endpoint' | 'username'>
): UserLocalHistory {
const history = this.storage.get<AccountKeyedChatHistory | null>(this.KEY_LOCAL_HISTORY, null)
const accountKey = getKeyForAuthStatus(authStatus)
return history?.[accountKey] ?? { chat: {} }
}

public async setChatHistory(
authStatus: AuthenticatedAuthStatus,
authStatus: Pick<AuthenticatedAuthStatus, 'endpoint' | 'username'>,
history: UserLocalHistory
): Promise<void> {
try {
Expand Down Expand Up @@ -324,7 +326,9 @@ class LocalStorage implements LocalStorageForModelPreferences {
*/
export const localStorage = new LocalStorage()

function getKeyForAuthStatus(authStatus: AuthenticatedAuthStatus): ChatHistoryKey {
function getKeyForAuthStatus(
authStatus: Pick<AuthenticatedAuthStatus, 'endpoint' | 'username'>
): ChatHistoryKey {
return `${authStatus.endpoint}-${authStatus.username}`
}

Expand Down
17 changes: 0 additions & 17 deletions vscode/webviews/App.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,6 @@ const dummyVSCodeAPI: VSCodeWrapper = {
workspaceFolderUris: [],
isDotComUser: true,
})
cb({
type: 'history',
localHistory: {
chat: {
a: {
id: 'a',
lastInteractionTimestamp: '2024-03-29',
interactions: [
{
humanMessage: { speaker: 'human', text: 'Hello, world!' },
assistantMessage: { speaker: 'assistant', text: 'Hi!' },
},
],
},
},
},
})
if (firstTime) {
cb({ type: 'view', view: View.Chat })
firstTime = false
Expand Down
7 changes: 0 additions & 7 deletions vscode/webviews/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
type ContextItem,
GuardrailsPost,
PromptString,
type SerializedChatTranscript,
type TelemetryRecorder,
} from '@sourcegraph/cody-shared'
import type { AuthMethod } from '../src/chat/protocol'
Expand All @@ -32,8 +31,6 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc

const [transcript, setTranscript] = useState<ChatMessage[]>([])

const [userHistory, setUserHistory] = useState<SerializedChatTranscript[]>()

const [errorMessages, setErrorMessages] = useState<string[]>([])

const dispatchClientAction = useClientActionDispatcher()
Expand Down Expand Up @@ -82,9 +79,6 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc
setView(View.Chat)
}
break
case 'history':
setUserHistory(Object.values(message.localHistory?.chat ?? {}))
break
case 'clientAction':
dispatchClientAction(message)
break
Expand Down Expand Up @@ -192,7 +186,6 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc
transcript={transcript}
vscodeAPI={vscodeAPI}
guardrails={guardrails}
userHistory={userHistory ?? []}
smartApplyEnabled={config.config.smartApply}
/>
)}
Expand Down
16 changes: 16 additions & 0 deletions vscode/webviews/AppWrapperForTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ResolvedConfiguration,
SYMBOL_CONTEXT_MENTION_PROVIDER,
type SymbolKind,
type UserLocalHistory,
getMockedDotComClientModels,
promiseFactoryToObservable,
} from '@sourcegraph/cody-shared'
Expand Down Expand Up @@ -96,6 +97,21 @@ export const AppWrapperForTest: FunctionComponent<{ children: ReactNode }> = ({
} satisfies Partial<ResolvedConfiguration> as ResolvedConfiguration),
authStatus: () => Observable.of(AUTH_STATUS_FIXTURE_AUTHED),
transcript: () => Observable.of(FIXTURE_TRANSCRIPT.explainCode),
userHistory: () =>
Observable.of<UserLocalHistory | null>({
chat: {
a: {
id: 'a',
lastInteractionTimestamp: '2024-03-29',
interactions: [
{
humanMessage: { speaker: 'human', text: 'Hello, world!' },
assistantMessage: { speaker: 'assistant', text: 'Hi!' },
},
],
},
},
}),
},
} satisfies Wrapper<ComponentProps<typeof ExtensionAPIProviderForTestsOnly>['value']>,
{
Expand Down
27 changes: 3 additions & 24 deletions vscode/webviews/CodyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type AuthStatus, type ClientCapabilities, CodyIDE } from '@sourcegraph/cody-shared'
import type React from 'react'
import { type ComponentProps, type FunctionComponent, useCallback, useRef } from 'react'
import { type ComponentProps, type FunctionComponent, useRef } from 'react'
import type { ConfigurationSubsetForWebview, LocalEnv } from '../src/chat/protocol'
import styles from './App.module.css'
import { Chat } from './Chat'
Expand Down Expand Up @@ -34,8 +34,7 @@ export const CodyPanel: FunctionComponent<
| 'showWelcomeMessage'
| 'showIDESnippetActions'
| 'smartApplyEnabled'
> &
Pick<ComponentProps<typeof HistoryTab>, 'userHistory'>
>
> = ({
view,
setView,
Expand All @@ -50,24 +49,10 @@ export const CodyPanel: FunctionComponent<
guardrails,
showIDESnippetActions,
showWelcomeMessage,
userHistory,
smartApplyEnabled,
}) => {
const tabContainerRef = useRef<HTMLDivElement>(null)

// Use native browser download dialog to download chat history as a JSON file.
const onDownloadChatClick = useCallback(() => {
const json = JSON.stringify(userHistory, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) // Format: YYYY-MM-DDTHH-mm
const a = document.createElement('a') // a temporary anchor element
a.href = url
a.download = `cody-chat-history-${timestamp}.json`
a.target = '_blank'
a.click()
}, [userHistory])

return (
<TabRoot
defaultValue={View.Chat}
Expand All @@ -80,12 +65,7 @@ export const CodyPanel: FunctionComponent<

{/* Hide tab bar in editor chat panels. */}
{(clientCapabilities.agentIDE === CodyIDE.Web || config.webviewType !== 'editor') && (
<TabsBar
currentView={view}
setView={setView}
IDE={clientCapabilities.agentIDE}
onDownloadChatClick={onDownloadChatClick}
/>
<TabsBar currentView={view} setView={setView} IDE={clientCapabilities.agentIDE} />
)}
{errorMessages && <ErrorBanner errors={errorMessages} setErrors={setErrorMessages} />}
<TabContainer value={view} ref={tabContainerRef}>
Expand All @@ -109,7 +89,6 @@ export const CodyPanel: FunctionComponent<
setView={setView}
webviewType={config.webviewType}
multipleWebviewsEnabled={config.multipleWebviewsEnabled}
userHistory={userHistory}
/>
)}
{view === View.Prompts && <PromptsTab setView={setView} />}
Expand Down
Loading
Loading