From 35c6cefd93c96735992dae8f29ec4b7ffc206e0c Mon Sep 17 00:00:00 2001 From: Deepak Kumar Date: Thu, 25 Jan 2024 06:43:23 +0530 Subject: [PATCH] Implement history quick pick (#2250) This PR closes #1692 - Implemented algorithm to group chats by last interaction timestamp. - Added an icon in the editor panel to display history chats as a quick pick, separated by the group. - Updated the history chats in the Treeview provider to render them as grouped chats. - Remove createCodyChatTreeItems, use the updateTree method of TreeViewProvider to update the list instead. ## Test plan https://github.com/sourcegraph/cody/assets/44617923/5804d8da-a629-4670-8de9-ff3b3da61e25 --------- Co-authored-by: Tim Lucas --- vscode/CHANGELOG.md | 3 + vscode/package.json | 20 ++- .../src/chat/chat-view/ChatPanelsManager.ts | 5 +- .../chat/chat-view/SimpleChatPanelProvider.ts | 3 +- vscode/src/main.ts | 4 + vscode/src/services/HistoryChat.ts | 151 ++++++++++++++++++ vscode/src/services/TreeViewProvider.test.ts | 10 +- vscode/src/services/TreeViewProvider.ts | 112 ++++++++++++- vscode/src/services/treeViewItems.ts | 38 +---- 9 files changed, 292 insertions(+), 54 deletions(-) create mode 100644 vscode/src/services/HistoryChat.ts diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md index edde1775da4c..ccc17da7a430 100644 --- a/vscode/CHANGELOG.md +++ b/vscode/CHANGELOG.md @@ -6,6 +6,7 @@ This is a log of all notable changes to Cody for VS Code. [Unreleased] changes a ### Added +- Chat: Add a history quick in the editor panel for chats grouped by last interaction timestamp. [pull/2250](https://github.com/sourcegraph/cody/pull/2250) - Commands: Custom edit commands are now executable from the Chat panel. [pull/2789](https://github.com/sourcegraph/cody/pull/2789) - [Internal] Edit/Chat: Added "ghost" text alongside code to showcase Edit and Chat commands. [pull/2611](https://github.com/sourcegraph/cody/pull/2611) - [Internal] Edit/Chat: Added Cmd/Ctrl+K and Cmd/Ctrl+L commands to trigger Edit and Chat [pull/2611](https://github.com/sourcegraph/cody/pull/2611) @@ -88,6 +89,8 @@ This is a log of all notable changes to Cody for VS Code. [Unreleased] changes a ### Changed +- Chat: Display chats in the treeview provider grouped by last interaction timestamp. [pull/2250](https://github.com/sourcegraph/cody/pull/2250) +- Autocomplete: Accepting a full line completion will not immedialty start another completion request on the same line. [pulls/2446](https://github.com/sourcegraph/cody/pull/2446) - Folders named 'bin/' are no longer filtered out from chat `@`-mentions but instead ranked lower. [pull/2472](https://github.com/sourcegraph/cody/pull/2472) - Files ignored in `.cody/ignore` (if the internal experiment is enabled) will no longer show up in chat `@`-mentions. [pull/2472](https://github.com/sourcegraph/cody/pull/2472) - Adds a new experiment to test a higher parameter StarCoder model for single-line completions. [pull/2632](https://github.com/sourcegraph/cody/pull/2632) diff --git a/vscode/package.json b/vscode/package.json index 51452f4474b3..729cd0a5f58c 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -449,6 +449,14 @@ "icon": "$(arrow-circle-down)", "when": "cody.activated && cody.hasChatHistory" }, + { + "command": "cody.chat.history.panel", + "category": "Cody", + "title": "Show History", + "group": "Cody", + "icon": "$(list-unordered)", + "when": "cody.activated && cody.hasChatHistory" + }, { "command": "cody.search.index-update", "category": "Cody", @@ -707,17 +715,23 @@ "when": "activeWebviewPanelId == cody.chatPanel && cody.activated", "group": "navigation@1", "visibility": "visible" + }, + { + "command": "cody.chat.history.panel", + "when": "activeWebviewPanelId == cody.chatPanel && cody.activated", + "group": "navigation", + "visibility": "visible" } ], "view/item/context": [ { "command": "cody.chat.history.edit", - "when": "view == cody.chat.tree.view && cody.activated && cody.hasChatHistory", + "when": "view == cody.chat.tree.view && cody.activated && cody.hasChatHistory && viewItem == cody.chats", "group": "inline@1" }, { "command": "cody.chat.history.delete", - "when": "view == cody.chat.tree.view && cody.activated && cody.hasChatHistory", + "when": "view == cody.chat.tree.view && cody.activated && cody.hasChatHistory && viewItem == cody.chats", "group": "inline@2" } ] @@ -1114,4 +1128,4 @@ "semver": "^7.5.4", "yaml": "^2.3.4" } -} +} \ No newline at end of file diff --git a/vscode/src/chat/chat-view/ChatPanelsManager.ts b/vscode/src/chat/chat-view/ChatPanelsManager.ts index be176093e8c8..2169d5aa0418 100644 --- a/vscode/src/chat/chat-view/ChatPanelsManager.ts +++ b/vscode/src/chat/chat-view/ChatPanelsManager.ts @@ -15,7 +15,6 @@ import type { SymfRunner } from '../../local-context/symf' import { logDebug } from '../../log' import { telemetryService } from '../../services/telemetry' import { telemetryRecorder } from '../../services/telemetry-v2' -import { createCodyChatTreeItems } from '../../services/treeViewItems' import { TreeViewProvider } from '../../services/TreeViewProvider' import type { CachedRemoteEmbeddingsClient } from '../CachedRemoteEmbeddingsClient' import type { MessageProviderOptions } from '../MessageProvider' @@ -231,9 +230,7 @@ export class ChatPanelsManager implements vscode.Disposable { } private async updateTreeViewHistory(): Promise { - await this.treeViewProvider.updateTree( - createCodyChatTreeItems(this.options.authProvider.getAuthStatus()) - ) + await this.treeViewProvider.updateTree(this.options.authProvider.getAuthStatus()) } public async editChatHistory(chatID: string, label: string): Promise { diff --git a/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts b/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts index 471017f95872..1b35ce68ca66 100644 --- a/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts +++ b/vscode/src/chat/chat-view/SimpleChatPanelProvider.ts @@ -51,7 +51,6 @@ import { getProcessInfo } from '../../services/LocalAppDetector' import { localStorage } from '../../services/LocalStorageProvider' import { telemetryService } from '../../services/telemetry' import { telemetryRecorder } from '../../services/telemetry-v2' -import { createCodyChatTreeItems } from '../../services/treeViewItems' import type { TreeViewProvider } from '../../services/TreeViewProvider' import { handleCodeFromInsertAtCursor, @@ -981,7 +980,7 @@ export class SimpleChatPanelProvider implements vscode.Disposable, ChatSession { messages: allHistory, }) } - await this.treeView.updateTree(createCodyChatTreeItems(this.authProvider.getAuthStatus())) + await this.treeView.updateTree(this.authProvider.getAuthStatus()) } public async clearAndRestartSession(): Promise { diff --git a/vscode/src/main.ts b/vscode/src/main.ts index cfe1d113c206..d0362ddc8db2 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -42,6 +42,7 @@ import { SearchViewProvider } from './search/SearchViewProvider' import { AuthProvider } from './services/AuthProvider' import { showFeedbackSupportQuickPick } from './services/FeedbackOptions' import { GuardrailsProvider } from './services/GuardrailsProvider' +import { displayHistoryQuickPick } from './services/HistoryChat' import { localStorage } from './services/LocalStorageProvider' import { getAccessToken, secretStorage, VSCodeSecretStorage } from './services/SecretStorageProvider' import { createStatusBar } from './services/StatusBar' @@ -380,6 +381,9 @@ const register = async ( query: '@ext:sourcegraph.cody-ai', }) ), + vscode.commands.registerCommand('cody.chat.history.panel', async () => { + await displayHistoryQuickPick(authProvider.getAuthStatus()) + }), vscode.commands.registerCommand('cody.settings.extension.chat', () => vscode.commands.executeCommand('workbench.action.openSettings', { query: '@ext:sourcegraph.cody-ai chat', diff --git a/vscode/src/services/HistoryChat.ts b/vscode/src/services/HistoryChat.ts new file mode 100644 index 000000000000..0aa5c1112ed0 --- /dev/null +++ b/vscode/src/services/HistoryChat.ts @@ -0,0 +1,151 @@ +import * as vscode from 'vscode' + +import { getChatPanelTitle } from '../chat/chat-view/chat-helpers' +import { chatHistory } from '../chat/chat-view/ChatHistoryManager' +import type { AuthStatus } from '../chat/protocol' + +import type { CodySidebarTreeItem } from './treeViewItems' +import type { InteractionMessage } from '@sourcegraph/cody-shared' + +interface GroupedChats { + [groupName: string]: CodySidebarTreeItem[] +} + +interface HistoryItem { + label: string + onSelect: () => Promise + kind?: vscode.QuickPickItemKind +} + +interface ChatGroup { + [groupName: string]: CodySidebarTreeItem[] +} + +const dateEqual = (d1: Date, d2: Date): boolean => { + return d1.getDate() === d2.getDate() && monthYearEqual(d1, d2) +} +const monthYearEqual = (d1: Date, d2: Date): boolean => { + return d1.getMonth() === d2.getMonth() && d1.getFullYear() === d2.getFullYear() +} + +export function groupCodyChats(authStatus: AuthStatus | undefined): GroupedChats | null { + const todayChats: CodySidebarTreeItem[] = [] + const yesterdayChats: CodySidebarTreeItem[] = [] + const thisMonthChats: CodySidebarTreeItem[] = [] + const lastMonthChats: CodySidebarTreeItem[] = [] + const nMonthsChats: CodySidebarTreeItem[] = [] + + const today = new Date() + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const lastMonth = new Date() + lastMonth.setDate(0) + + const chatGroups: ChatGroup = { + Today: todayChats, + Yesterday: yesterdayChats, + 'This month': thisMonthChats, + 'Last month': lastMonthChats, + 'N months ago': nMonthsChats, + } + + if (!authStatus) { + return null + } + + const chats = chatHistory.getLocalHistory(authStatus)?.chat + if (!chats) { + return null + } + const chatHistoryEntries = [...Object.entries(chats)] + for (const [id, entry] of chatHistoryEntries) { + let lastHumanMessage: InteractionMessage | undefined = undefined + // Can use Array.prototype.findLast once we drop Node 16 + for (let index = entry.interactions.length - 1; index >= 0; index--) { + lastHumanMessage = entry.interactions[index]?.humanMessage + if (lastHumanMessage) { + break + } + } + if (lastHumanMessage?.displayText && lastHumanMessage?.text) { + const lastDisplayText = lastHumanMessage.displayText.split('\n')[0] + const chatTitle = chats[id].chatTitle || getChatPanelTitle(lastDisplayText, false) + + const lastInteractionTimestamp = new Date(entry.lastInteractionTimestamp) + let groupLabel = 'N months ago' + + if (dateEqual(today, lastInteractionTimestamp)) { + groupLabel = 'Today' + } else if (dateEqual(yesterday, lastInteractionTimestamp)) { + groupLabel = 'Yesterday' + } else if (monthYearEqual(today, lastInteractionTimestamp)) { + groupLabel = 'This month' + } else if (monthYearEqual(lastMonth, lastInteractionTimestamp)) { + groupLabel = 'Last month' + } + + const chatGroup = chatGroups[groupLabel] + chatGroup.push({ + id, + title: chatTitle, + icon: 'comment-discussion', + command: { + command: 'cody.chat.panel.restore', + args: [id, chatTitle], + }, + }) + } + } + + return { + Today: todayChats.reverse(), + Yesterday: yesterdayChats.reverse(), + 'This month': thisMonthChats.reverse(), + 'Last month': lastMonthChats.reverse(), + 'N months ago': nMonthsChats.reverse(), + } +} + +export async function displayHistoryQuickPick(authStatus: AuthStatus): Promise { + const groupedChats = groupCodyChats(authStatus) + if (!groupedChats) { + return + } + + const quickPickItems: HistoryItem[] = [] + + const addGroupSeparator = (groupName: string): void => { + quickPickItems.push({ + label: groupName, + onSelect: async () => {}, + kind: vscode.QuickPickItemKind.Separator, + }) + } + + for (const [groupName, chats] of Object.entries(groupedChats)) { + if (chats.length > 0) { + addGroupSeparator(groupName) + + for (const chat of chats) { + quickPickItems.push({ + label: chat.title, + onSelect: async () => { + await vscode.commands.executeCommand( + 'cody.chat.panel.restore', + chat.id, + chat.title + ) + }, + }) + } + } + } + + const selectedItem = await vscode.window.showQuickPick(quickPickItems, { + placeHolder: 'Search chat history', + }) + + if (selectedItem?.onSelect) { + await selectedItem.onSelect() + } +} diff --git a/vscode/src/services/TreeViewProvider.test.ts b/vscode/src/services/TreeViewProvider.test.ts index 7dab75efe792..d25bf49b7fe6 100644 --- a/vscode/src/services/TreeViewProvider.test.ts +++ b/vscode/src/services/TreeViewProvider.test.ts @@ -67,8 +67,8 @@ describe('TreeViewProvider', () => { return nextUpdate } - function findTreeItem(label: string) { - const items = tree.getChildren() + async function findTreeItem(label: string) { + const items = await tree.getChildren() return items.find(item => (item.resourceUri as any)?.label === label) } @@ -76,19 +76,19 @@ describe('TreeViewProvider', () => { it('is shown when user can upgrade', async () => { tree = new TreeViewProvider('support', emptyMockFeatureFlagProvider) await updateTree({ upgradeAvailable: true, endpoint: DOTCOM_URL }) - expect(findTreeItem('Upgrade')).not.toBeUndefined() + expect(await findTreeItem('Upgrade')).not.toBeUndefined() }) it('is not shown when user cannot upgrade', async () => { tree = new TreeViewProvider('support', emptyMockFeatureFlagProvider) await updateTree({ upgradeAvailable: false, endpoint: DOTCOM_URL }) - expect(findTreeItem('Upgrade')).toBeUndefined() + expect(await findTreeItem('Upgrade')).toBeUndefined() }) it('is not shown when not dotCom regardless of GA or upgrade flags', async () => { tree = new TreeViewProvider('support', emptyMockFeatureFlagProvider) await updateTree({ upgradeAvailable: true, endpoint: new URL('https://example.org') }) - expect(findTreeItem('Upgrade')).toBeUndefined() + expect(await findTreeItem('Upgrade')).toBeUndefined() }) }) }) diff --git a/vscode/src/services/TreeViewProvider.ts b/vscode/src/services/TreeViewProvider.ts index 475997166e62..5f8c370c87d7 100644 --- a/vscode/src/services/TreeViewProvider.ts +++ b/vscode/src/services/TreeViewProvider.ts @@ -5,8 +5,47 @@ import { isDotCom, type FeatureFlagProvider } from '@sourcegraph/cody-shared' import type { AuthStatus } from '../chat/protocol' import { getFullConfig } from '../configuration' +import { groupCodyChats } from './HistoryChat' import { getCodyTreeItems, type CodySidebarTreeItem, type CodyTreeItemType } from './treeViewItems' +export class ChatTreeItem extends vscode.TreeItem { + public children: ChatTreeItem[] | undefined + + constructor( + public readonly id: string, + title: string, + icon?: string, + command?: { + command: string + args?: string[] | { [key: string]: string }[] + }, + contextValue?: string, + collapsibleState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.None, + children?: ChatTreeItem[] + ) { + super(title, collapsibleState) + this.id = id + if (icon) { + this.iconPath = new vscode.ThemeIcon(icon) + } + if (command) { + this.command = { + command: command.command, + title, + arguments: command.args, + } + } + if (contextValue) { + this.contextValue = contextValue + } + this.children = children + } + public async loadChildNodes(): Promise { + await Promise.resolve() + return this.children + } +} + export class TreeViewProvider implements vscode.TreeDataProvider { private treeNodes: vscode.TreeItem[] = [] private _disposables: vscode.Disposable[] = [] @@ -15,6 +54,8 @@ export class TreeViewProvider implements vscode.TreeDataProvider { - this.treeItems = treeItems + public async updateTree(authStatus: AuthStatus, treeItems?: CodySidebarTreeItem[]): Promise { + if (treeItems) { + this.treeItems = treeItems + } + this.authStatus = authStatus return this.refresh() } @@ -95,6 +139,7 @@ export class TreeViewProvider implements vscode.TreeDataProvider { + const groupedChats = groupCodyChats(this.authStatus) + if (!groupedChats) { + return + } + + this.treeNodes = [] + + let firstGroup = true + + // Create a ChatTreeItem for each group and add to treeNodes + for (const [groupLabel, chats] of Object.entries(groupedChats)) { + // only display the group in the treeview for which chat exists + + if (chats.length) { + const collapsibleState = + firstGroup || chats.some(chat => this.revivedChatItems.includes(chat.id as string)) + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed + + const groupItem = new ChatTreeItem( + groupLabel, + groupLabel, + undefined, + undefined, + undefined, + collapsibleState, + chats.map( + chat => + new ChatTreeItem( + chat.id as string, + chat.title, + chat.icon, + chat.command, + 'cody.chats' + ) + ) + ) + if (collapsibleState === vscode.TreeItemCollapsibleState.Expanded) { + this._onDidChangeTreeData.fire(groupItem) + } + + this.treeNodes.push(groupItem) + firstGroup = false + } + } + await Promise.resolve() + } + public syncAuthStatus(authStatus: AuthStatus): void { this.authStatus = authStatus void this.refresh() @@ -113,8 +210,15 @@ export class TreeViewProvider implements vscode.TreeDataProvider { + if (element) { + // Load children if not already loaded + if (!element.children) { + await element.loadChildNodes() + } + return element.children || [] + } + return this.treeNodes as ChatTreeItem[] } /** diff --git a/vscode/src/services/treeViewItems.ts b/vscode/src/services/treeViewItems.ts index f9686206c955..96557b9b69fc 100644 --- a/vscode/src/services/treeViewItems.ts +++ b/vscode/src/services/treeViewItems.ts @@ -1,14 +1,9 @@ -import { findLast } from 'lodash' - import type { FeatureFlag } from '@sourcegraph/cody-shared' -import { getChatPanelTitle } from '../chat/chat-view/chat-helpers' -import { CODY_DOC_URL, CODY_FEEDBACK_URL, DISCORD_URL, type AuthStatus } from '../chat/protocol' +import { CODY_DOC_URL, CODY_FEEDBACK_URL, DISCORD_URL } from '../chat/protocol' import { releaseNotesURL, releaseType } from '../release' import { version } from '../version' -import { localStorage } from './LocalStorageProvider' - export type CodyTreeItemType = 'command' | 'support' | 'search' | 'chat' export interface CodySidebarTreeItem { @@ -20,7 +15,7 @@ export interface CodySidebarTreeItem { command: string args?: string[] | { [key: string]: string }[] } - isNestedItem?: string + isNestedItem?: boolean requireFeature?: FeatureFlag requireUpgradeAvailable?: boolean requireDotCom?: boolean @@ -42,35 +37,6 @@ export function getCodyTreeItems(type: CodyTreeItemType): CodySidebarTreeItem[] } } -// functon to create chat tree items from user chat history -export function createCodyChatTreeItems(authStatus: AuthStatus): CodySidebarTreeItem[] { - const userHistory = localStorage.getChatHistory(authStatus)?.chat - if (!userHistory) { - return [] - } - const chatTreeItems: CodySidebarTreeItem[] = [] - const chatHistoryEntries = [...Object.entries(userHistory)] - for (const [id, entry] of chatHistoryEntries) { - const lastHumanMessage = findLast( - entry?.interactions, - message => message.humanMessage.displayText !== undefined - ) - if (lastHumanMessage?.humanMessage?.displayText) { - const lastDisplayText = lastHumanMessage.humanMessage.displayText.split('\n')[0] - chatTreeItems.push({ - id, - title: entry.chatTitle || getChatPanelTitle(lastDisplayText, false), - icon: 'comment-discussion', - command: { - command: 'cody.chat.panel.restore', - args: [id, entry.chatTitle || getChatPanelTitle(lastDisplayText)], - }, - }) - } - } - return chatTreeItems.reverse() -} - const supportItems: CodySidebarTreeItem[] = [ { title: 'Upgrade',