diff --git a/clients/tabby-chat-panel/package.json b/clients/tabby-chat-panel/package.json index ca4726646d4f..ee4608375f5d 100644 --- a/clients/tabby-chat-panel/package.json +++ b/clients/tabby-chat-panel/package.json @@ -1,7 +1,7 @@ { "name": "tabby-chat-panel", "type": "module", - "version": "0.4.0", + "version": "0.5.0", "keywords": [], "sideEffects": false, "exports": { diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index 08f419e295f7..7cc91c86c9c4 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -51,15 +51,35 @@ export interface LineRange { */ export type Location = number | LineRange | Position | PositionRange -export interface FileContext { +/** + * Represents a client-side file context. + * This type should only be used for send context from client to server. + */ +export interface ClientFileContext { kind: 'file' - range: LineRange - filepath: string + + /** + * The filepath of the file. + */ + filepath: Filepath + + /** + * The range of the selected content in the file. + * If the range is not provided, the whole file is considered. + */ + range?: LineRange | PositionRange + + /** + * The content of the file context. + */ content: string - git_url: string } -export type Context = FileContext +/** + * Represents a client-side context. + * This type should only be used for send context from client to server. + */ +export type ClientSideContext = ClientFileContext export interface FetcherOptions { authorization: string @@ -83,10 +103,6 @@ export interface ErrorMessage { content: string } -export interface NavigateOpts { - openInEditor?: boolean -} - /** * Represents a filepath to identify a file. */ @@ -184,22 +200,34 @@ export interface GitRepository { url: string } +/** + * Predefined commands. + * - 'explain': Explain the selected code. + * - 'fix': Fix bugs in the selected code. + * - 'generate-docs': Generate documentation for the selected code. + * - 'generate-tests': Generate tests for the selected code. + */ +export type ChatCommand = 'explain' | 'fix' | 'generate-docs' | 'generate-tests' + export interface ServerApi { init: (request: InitRequest) => void - sendMessage: (message: ChatMessage) => void + + /** + * Execute a predefined command. + * @param command The command to execute. + */ + executeCommand: (command: ChatCommand) => Promise + showError: (error: ErrorMessage) => void cleanError: () => void - addRelevantContext: (context: Context) => void + addRelevantContext: (context: ClientSideContext) => void updateTheme: (style: string, themeClass: string) => void - updateActiveSelection: (context: Context | null) => void + updateActiveSelection: (context: ClientFileContext | null) => void } export interface ClientApiMethods { - navigate: (context: Context, opts?: NavigateOpts) => void refresh: () => Promise - onSubmitMessage: (msg: string, relevantContext?: Context[]) => Promise - // apply content into active editor, version 1, not support smart apply onApplyInEditor: (content: string) => void @@ -232,35 +260,29 @@ export interface ClientApiMethods { */ openInEditor: (target: FileLocation) => Promise + /** + * Open the target URL in the external browser. + * @param url The target URL to open. + */ + openExternal: (url: string) => Promise + // Provide all repos found in workspace folders. readWorkspaceGitRepositories?: () => Promise } export interface ClientApi extends ClientApiMethods { - // this is inner function cover by tabby-threads - // the function doesn't need to expose to client but can call by client + /** + * Checks if the client supports this capability. + * This method is designed to check capability across different clients (IDEs). + * Note: This method should not be used to ensure compatibility across different API versions. + */ hasCapability: (method: keyof ClientApiMethods) => Promise } -export interface ChatMessage { - message: string - - // Client side context - displayed in user message - selectContext?: Context - - // Client side contexts - displayed in assistant message - relevantContext?: Array - - // Client side active selection context - displayed in assistant message - activeContext?: Context -} - export function createClient(target: HTMLIFrameElement, api: ClientApiMethods): ServerApi { return createThreadFromIframe(target, { expose: { - navigate: api.navigate, refresh: api.refresh, - onSubmitMessage: api.onSubmitMessage, onApplyInEditor: api.onApplyInEditor, onApplyInEditorV2: api.onApplyInEditorV2, onLoaded: api.onLoaded, @@ -268,6 +290,7 @@ export function createClient(target: HTMLIFrameElement, api: ClientApiMethods): onKeyboardEvent: api.onKeyboardEvent, lookupSymbol: api.lookupSymbol, openInEditor: api.openInEditor, + openExternal: api.openExternal, readWorkspaceGitRepositories: api.readWorkspaceGitRepositories, }, }) @@ -277,7 +300,7 @@ export function createServer(api: ServerApi): ClientApi { return createThreadFromInsideIframe({ expose: { init: api.init, - sendMessage: api.sendMessage, + executeCommand: api.executeCommand, showError: api.showError, cleanError: api.cleanError, addRelevantContext: api.addRelevantContext, diff --git a/clients/vscode/src/chat/ChatPanelViewProvider.ts b/clients/vscode/src/chat/ChatPanelViewProvider.ts index e0a5de7509bc..6def58597907 100644 --- a/clients/vscode/src/chat/ChatPanelViewProvider.ts +++ b/clients/vscode/src/chat/ChatPanelViewProvider.ts @@ -1,5 +1,5 @@ import { ExtensionContext, window, WebviewPanel } from "vscode"; -import type { ServerApi, ChatMessage, Context } from "tabby-chat-panel"; +import type { ServerApi, ChatCommand, ClientFileContext } from "tabby-chat-panel"; import { WebviewHelper } from "./WebviewHelper"; import { Client } from "../lsp/Client"; import { GitProvider } from "../git/GitProvider"; @@ -57,11 +57,11 @@ export class ChatPanelViewProvider { return this.webview; } - public sendMessage(message: ChatMessage) { - this.webviewHelper.sendMessage(message); + public executeCommand(command: ChatCommand) { + this.webviewHelper.executeCommand(command); } - public addRelevantContext(context: Context) { + public addRelevantContext(context: ClientFileContext) { this.webviewHelper.addRelevantContext(context); } } diff --git a/clients/vscode/src/chat/ChatSideViewProvider.ts b/clients/vscode/src/chat/ChatSideViewProvider.ts index ca548ae1694d..d6a89ac410bb 100644 --- a/clients/vscode/src/chat/ChatSideViewProvider.ts +++ b/clients/vscode/src/chat/ChatSideViewProvider.ts @@ -1,5 +1,5 @@ import { ExtensionContext, WebviewViewProvider, WebviewView, window } from "vscode"; -import type { ServerApi, ChatMessage, Context } from "tabby-chat-panel"; +import type { ServerApi, ChatCommand, ClientFileContext } from "tabby-chat-panel"; import { WebviewHelper } from "./WebviewHelper"; import { Client } from "../lsp/Client"; import type { LogOutputChannel } from "../logger"; @@ -60,11 +60,11 @@ export class ChatSideViewProvider implements WebviewViewProvider { return this.webview; } - public sendMessage(message: ChatMessage) { - this.webviewHelper.sendMessage(message); + public executeCommand(command: ChatCommand) { + this.webviewHelper.executeCommand(command); } - public addRelevantContext(context: Context) { + public addRelevantContext(context: ClientFileContext) { this.webviewHelper.addRelevantContext(context); } } diff --git a/clients/vscode/src/chat/WebviewHelper.ts b/clients/vscode/src/chat/WebviewHelper.ts index 3b2789748b4f..c2416da26370 100644 --- a/clients/vscode/src/chat/WebviewHelper.ts +++ b/clients/vscode/src/chat/WebviewHelper.ts @@ -17,9 +17,8 @@ import { } from "vscode"; import type { ServerApi, - ChatMessage, - Context, - NavigateOpts, + ChatCommand, + ClientFileContext, OnLoadedParams, LookupSymbolHint, SymbolInfo, @@ -35,7 +34,7 @@ import { GitProvider } from "../git/GitProvider"; import { createClient } from "./chatPanel"; import { Client as LspClient } from "../lsp/Client"; import { isBrowser } from "../env"; -import { getFileContextFromSelection, showFileContext, openTextDocument, buildFilePathParams } from "./fileContext"; +import { getFileContextFromSelection } from "./fileContext"; import { localUriToChatPanelFilepath, chatPanelFilepathToLocalUri, @@ -47,8 +46,7 @@ import { export class WebviewHelper { webview?: Webview; client?: ServerApi; - private pendingMessages: ChatMessage[] = []; - private pendingRelevantContexts: Context[] = []; + private pendingActions: (() => void)[] = []; private isChatPageDisplayed = false; constructor( @@ -270,12 +268,7 @@ export class WebviewHelper { return supportedSchemes.includes(scheme); } - public sendMessageToChatPanel(message: ChatMessage) { - this.logger.info(`Sending message to chat panel: ${JSON.stringify(message)}`); - this.client?.sendMessage(message); - } - - public async syncActiveSelectionToChatPanel(context: Context | null) { + public async syncActiveSelectionToChatPanel(context: ClientFileContext | null) { try { await this.client?.updateActiveSelection(context); } catch { @@ -289,11 +282,25 @@ export class WebviewHelper { } } - public addRelevantContext(context: Context) { - if (!this.client) { - this.pendingRelevantContexts.push(context); + public addRelevantContext(context: ClientFileContext) { + if (this.client) { + this.client.addRelevantContext(context); + } else { + this.pendingActions.push(() => { + this.client?.addRelevantContext(context); + }); + } + } + + public executeCommand(command: ChatCommand) { + if (this.client) { + this.logger.info(`Executing command: ${command}`); + this.client.executeCommand(command); } else { - this.client?.addRelevantContext(context); + this.pendingActions.push(() => { + this.logger.info(`Executing pending command: ${command}`); + this.client?.executeCommand(command); + }); } } @@ -313,11 +320,8 @@ export class WebviewHelper { return; } - this.pendingRelevantContexts.forEach((ctx) => this.addRelevantContext(ctx)); - this.pendingRelevantContexts = []; - - this.pendingMessages.forEach((message) => this.sendMessageToChatPanel(message)); - this.pendingMessages = []; + this.pendingActions.forEach((fn) => fn()); + this.pendingActions = []; this.syncActiveSelection(window.activeTextEditor); @@ -337,32 +341,6 @@ export class WebviewHelper { } } - public formatLineHashForCodeBrowser( - range: - | { - start: number; - end?: number; - } - | undefined, - ): string { - if (!range) return ""; - const { start, end } = range; - if (typeof start !== "number") return ""; - if (start === end) return `L${start}`; - return [start, end] - .map((num) => (typeof num === "number" ? `L${num}` : undefined)) - .filter((o) => o !== undefined) - .join("-"); - } - - public sendMessage(message: ChatMessage) { - if (!this.client) { - this.pendingMessages.push(message); - } else { - this.sendMessageToChatPanel(message); - } - } - public async syncActiveSelection(editor: TextEditor | undefined) { if (!editor || !this.isSupportedSchemeForActiveSelection(editor.document.uri.scheme)) { this.syncActiveSelectionToChatPanel(null); @@ -444,55 +422,11 @@ export class WebviewHelper { }; return createClient(webview, { - navigate: async (context: Context, opts?: NavigateOpts) => { - if (opts?.openInEditor) { - showFileContext(context, this.gitProvider); - return; - } - - if (context?.filepath && context?.git_url) { - const agentConfig = this.lspClient.agentConfig.current; - - const url = new URL(`${agentConfig?.server.endpoint}/files`); - const searchParams = new URLSearchParams(); - - searchParams.append("redirect_filepath", context.filepath); - searchParams.append("redirect_git_url", context.git_url); - url.search = searchParams.toString(); - - const lineHash = this.formatLineHashForCodeBrowser(context.range); - if (lineHash) { - url.hash = lineHash; - } - - await env.openExternal(Uri.parse(url.toString())); - } - }, refresh: async () => { const agentConfig = await this.lspClient.agentConfig.fetchAgentConfig(); await this.displayChatPage(agentConfig.server.endpoint, { force: true }); return; }, - onSubmitMessage: async (msg: string, relevantContext?: Context[]) => { - const editor = window.activeTextEditor; - const chatMessage: ChatMessage = { - message: msg, - relevantContext: [], - }; - // FIXME: after synchronizing the activeSelection, perhaps there's no need to include activeSelection in the message. - if (editor && this.isSupportedSchemeForActiveSelection(editor.document.uri.scheme)) { - const fileContext = await getFileContextFromSelection(editor, this.gitProvider); - if (fileContext) - // active selection - chatMessage.activeContext = fileContext; - } - if (relevantContext) { - chatMessage.relevantContext = chatMessage.relevantContext?.concat(relevantContext); - } - - // FIXME: maybe deduplicate on chatMessage.relevantContext - this.sendMessage(chatMessage); - }, onApplyInEditor: async (content: string) => { const editor = window.activeTextEditor; if (!editor) { @@ -593,7 +527,14 @@ export class WebviewHelper { if (!uri) { continue; } - const document = await openTextDocument({ filePath: uri.toString(true) }, this.gitProvider); + + let document: TextDocument; + try { + document = await workspace.openTextDocument(uri); + } catch (error) { + this.logger.debug("Failed to open document:", uri, error); + continue; + } if (!document) { continue; } @@ -685,6 +626,16 @@ export class WebviewHelper { return false; } + if (uri.scheme === "output") { + try { + await commands.executeCommand(`workbench.action.output.show.${uri.fsPath}`); + return true; + } catch (error) { + this.logger.error("Failed to open output channel:", fileLocation, error); + return false; + } + } + const targetRange = chatPanelLocationToVSCodeRange(fileLocation.location) ?? new Range(0, 0, 0, 0); try { await commands.executeCommand( @@ -700,17 +651,22 @@ export class WebviewHelper { return false; } }, + openExternal: async (url: string) => { + await env.openExternal(Uri.parse(url)); + }, readWorkspaceGitRepositories: async (): Promise => { const activeTextEditor = window.activeTextEditor; const infoList: GitRepository[] = []; let activeGitUrl: string | undefined; if (activeTextEditor) { - const pathParams = await buildFilePathParams(activeTextEditor.document.uri, this.gitProvider); - if (pathParams.gitRemoteUrl) { - activeGitUrl = pathParams.gitRemoteUrl; - infoList.push({ - url: activeGitUrl, - }); + const repo = this.gitProvider.getRepository(activeTextEditor.document.uri); + if (repo) { + const gitRemoteUrl = this.gitProvider.getDefaultRemoteUrl(repo); + if (gitRemoteUrl) { + infoList.push({ + url: gitRemoteUrl, + }); + } } } diff --git a/clients/vscode/src/chat/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts index 356a85a2d343..94819b689741 100644 --- a/clients/vscode/src/chat/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -25,9 +25,7 @@ export function createThreadFromWebview, Target = R export function createClient(webview: Webview, api: ClientApiMethods): ServerApi { return createThreadFromWebview(webview, { expose: { - navigate: api.navigate, refresh: api.refresh, - onSubmitMessage: api.onSubmitMessage, onApplyInEditor: api.onApplyInEditor, onApplyInEditorV2: api.onApplyInEditorV2, onLoaded: api.onLoaded, @@ -35,6 +33,7 @@ export function createClient(webview: Webview, api: ClientApiMethods): ServerApi onKeyboardEvent: api.onKeyboardEvent, lookupSymbol: api.lookupSymbol, openInEditor: api.openInEditor, + openExternal: api.openExternal, readWorkspaceGitRepositories: api.readWorkspaceGitRepositories, }, }); diff --git a/clients/vscode/src/chat/fileContext.ts b/clients/vscode/src/chat/fileContext.ts index 8cf6dbb50fce..4311ac0d89dd 100644 --- a/clients/vscode/src/chat/fileContext.ts +++ b/clients/vscode/src/chat/fileContext.ts @@ -1,21 +1,12 @@ -import type { TextEditor, TextDocument } from "vscode"; -import type { FileContext } from "tabby-chat-panel"; +import type { TextEditor } from "vscode"; +import type { ClientFileContext } from "tabby-chat-panel"; import type { GitProvider } from "../git/GitProvider"; -import { workspace, window, Position, Range, Selection, TextEditorRevealType, Uri, ViewColumn, commands } from "vscode"; -import path from "path"; -import { getLogger } from "../logger"; - -const logger = getLogger("FileContext"); - -export interface FilePathParams { - filePath: string; - gitRemoteUrl?: string; -} +import { localUriToChatPanelFilepath } from "./utils"; export async function getFileContextFromSelection( editor: TextEditor, gitProvider: GitProvider, -): Promise { +): Promise { return getFileContext(editor, gitProvider, true); } @@ -23,7 +14,7 @@ export async function getFileContext( editor: TextEditor, gitProvider: GitProvider, useSelection = false, -): Promise { +): Promise { const text = editor.document.getText(useSelection ? editor.selection : undefined); if (!text || text.trim().length < 1) { return null; @@ -39,137 +30,16 @@ export async function getFileContext( end: editor.document.lineCount, }; - const filePathParams = await buildFilePathParams(editor.document.uri, gitProvider); + const filepath = localUriToChatPanelFilepath(editor.document.uri, gitProvider); return { kind: "file", - content, + filepath, range, - filepath: filePathParams.filePath, - git_url: filePathParams.gitRemoteUrl ?? "", - }; -} - -export async function showFileContext(fileContext: FileContext, gitProvider: GitProvider): Promise { - if (fileContext.filepath.startsWith("output:")) { - const channelId = Uri.parse(fileContext.filepath).fsPath; - await commands.executeCommand(`workbench.action.output.show.${channelId}`); - return; - } - - const document = await openTextDocument( - { - filePath: fileContext.filepath, - gitRemoteUrl: fileContext.git_url, - }, - gitProvider, - ); - if (!document) { - throw new Error(`File not found: ${fileContext.filepath}`); - } - - const editor = await window.showTextDocument(document, { - viewColumn: ViewColumn.Active, - preview: false, - preserveFocus: true, - }); - - // Move the cursor to the specified line - const start = new Position(Math.max(0, fileContext.range.start - 1), 0); - const end = new Position(fileContext.range.end, 0); - editor.selection = new Selection(start, end); - editor.revealRange(new Range(start, end), TextEditorRevealType.InCenter); -} - -export async function buildFilePathParams(uri: Uri, gitProvider: GitProvider): Promise { - const workspaceFolder = - workspace.getWorkspaceFolder(uri) ?? (uri.scheme === "untitled" ? workspace.workspaceFolders?.[0] : undefined); - const repo = - gitProvider.getRepository(uri) ?? (workspaceFolder ? gitProvider.getRepository(workspaceFolder.uri) : undefined); - const gitRemoteUrl = repo ? gitProvider.getDefaultRemoteUrl(repo) : undefined; - let filePath = uri.toString(true); - if (repo && gitRemoteUrl) { - const relativeFilePath = path.relative(repo.rootUri.toString(true), filePath); - if (!relativeFilePath.startsWith("..")) { - filePath = relativeFilePath; - } - } else if (workspaceFolder) { - const relativeFilePath = path.relative(workspaceFolder.uri.toString(true), filePath); - if (!relativeFilePath.startsWith("..")) { - filePath = relativeFilePath; - } - } - return { - filePath, - gitRemoteUrl, + content, }; } -export async function openTextDocument( - filePathParams: FilePathParams, - gitProvider: GitProvider, -): Promise { - const { filePath, gitRemoteUrl } = filePathParams; - - // Try parse as absolute path - try { - const absoluteFilepath = Uri.parse(filePath, true); - if (absoluteFilepath.scheme) { - return await workspace.openTextDocument(absoluteFilepath); - } - } catch (err) { - // ignore - } - - // Try find file in provided git repository - if (gitRemoteUrl && gitRemoteUrl.trim().length > 0) { - const localGitRoot = gitProvider.findLocalRootUriByRemoteUrl(gitRemoteUrl); - if (localGitRoot) { - try { - const absoluteFilepath = Uri.joinPath(localGitRoot, filePath); - return await workspace.openTextDocument(absoluteFilepath); - } catch (err) { - // ignore - } - } - } - - for (const root of workspace.workspaceFolders ?? []) { - // Try find file in workspace folder - const absoluteFilepath = Uri.joinPath(root.uri, filePath); - try { - return await workspace.openTextDocument(absoluteFilepath); - } catch (err) { - // ignore - } - - // Try find file in git repository of workspace folder - const localGitRoot = gitProvider.getRepository(root.uri)?.rootUri; - if (localGitRoot) { - try { - const absoluteFilepath = Uri.joinPath(localGitRoot, filePath); - return await workspace.openTextDocument(absoluteFilepath); - } catch (err) { - // ignore - } - } - } - - // Try find file in workspace folders using workspace.findFiles - logger.info("File not found in workspace folders, trying with findFiles..."); - const files = await workspace.findFiles(filePath, undefined, 1); - if (files[0]) { - try { - return await workspace.openTextDocument(files[0]); - } catch (err) { - // ignore - } - } - - logger.warn(`File not found: ${filePath}`); - return null; -} - function alignIndent(text: string): string { const lines = text.split("\n"); const subsequentLines = lines.slice(1); diff --git a/clients/vscode/src/commands/index.ts b/clients/vscode/src/commands/index.ts index 1b19e231cd0b..2d9c7efafcc8 100644 --- a/clients/vscode/src/commands/index.ts +++ b/clients/vscode/src/commands/index.ts @@ -16,6 +16,7 @@ import { import os from "os"; import path from "path"; import { StatusIssuesName } from "tabby-agent"; +import type { ChatCommand } from "tabby-chat-panel"; import { Client } from "../lsp/Client"; import { Config } from "../Config"; import { ContextVariables } from "../ContextVariables"; @@ -50,20 +51,17 @@ export class Commands { this.context.subscriptions.push(...registrations); } - private async sendMessageToChatPanel(msg: string) { + private async chatPanelExecuteCommand(command: ChatCommand) { const editor = window.activeTextEditor; if (editor) { commands.executeCommand("tabby.chatView.focus"); - const fileContext = await getFileContextFromSelection(editor, this.gitProvider); - if (!fileContext) { + + if (editor.selection.isEmpty) { window.showInformationMessage("No selected codes"); return; } - this.chatViewProvider.sendMessage({ - message: msg, - selectContext: fileContext, - }); + this.chatViewProvider.executeCommand(command); } else { window.showInformationMessage("No active editor"); } @@ -224,8 +222,11 @@ export class Commands { "status.resetIgnoredIssues": () => { this.client.status.editIgnoredIssues({ operation: "removeAll", issues: [] }); }, - "chat.explainCodeBlock": async (userCommand?: string) => { - this.sendMessageToChatPanel("Explain the selected code:".concat(userCommand ? `\n${userCommand}` : "")); + "chat.explainCodeBlock": async (/* userCommand?: string */) => { + // @FIXME(@icycodes): The `userCommand` is not being used + // When invoked from code-action/quick-fix, it contains the error message provided by the IDE + + this.chatPanelExecuteCommand("explain"); }, "chat.addRelevantContext": async () => { this.addRelevantContext(); @@ -244,13 +245,13 @@ export class Commands { } }, "chat.fixCodeBlock": async () => { - this.sendMessageToChatPanel("Identify and fix potential bugs in the selected code:"); + this.chatPanelExecuteCommand("fix"); }, "chat.generateCodeBlockDoc": async () => { - this.sendMessageToChatPanel("Generate documentation for the selected code:"); + this.chatPanelExecuteCommand("generate-docs"); }, "chat.generateCodeBlockTest": async () => { - this.sendMessageToChatPanel("Generate a unit test for the selected code:"); + this.chatPanelExecuteCommand("generate-tests"); }, "chat.createPanel": async () => { const panel = window.createWebviewPanel("tabby.chatView", "Tabby", ViewColumn.One, { diff --git a/ee/tabby-ui/app/chat/page.tsx b/ee/tabby-ui/app/chat/page.tsx index 3fdc0d0f2c67..2a6b1e68bf47 100644 --- a/ee/tabby-ui/app/chat/page.tsx +++ b/ee/tabby-ui/app/chat/page.tsx @@ -11,12 +11,12 @@ import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { TABBY_CHAT_PANEL_API_VERSION, - type ChatMessage, - type Context, + type ChatCommand, + type ClientFileContext, type ErrorMessage, type FetcherOptions, - type InitRequest, - type NavigateOpts + type FileLocation, + type InitRequest } from 'tabby-chat-panel' import { useServer } from 'tabby-chat-panel/react' @@ -51,12 +51,12 @@ export default function ChatPage() { null ) const [activeChatId, setActiveChatId] = useState('') - const [pendingMessages, setPendingMessages] = useState([]) + const [pendingCommand, setPendingCommand] = useState() const [pendingRelevantContexts, setPendingRelevantContexts] = useState< - Context[] + ClientFileContext[] >([]) const [pendingActiveSelection, setPendingActiveSelection] = - useState(null) + useState(null) const [errorMessage, setErrorMessage] = useState(null) const [isRefreshLoading, setIsRefreshLoading] = useState(false) @@ -80,17 +80,15 @@ export default function ChatPage() { setSupportsProvideWorkspaceGitRepoInfo ] = useState(false) - const sendMessage = (message: ChatMessage) => { + const executeCommand = (command: ChatCommand) => { if (chatRef.current) { - chatRef.current.sendUserChat(message) + chatRef.current.executeCommand(command) } else { - const newPendingMessages = [...pendingMessages] - newPendingMessages.push(message) - setPendingMessages(newPendingMessages) + setPendingCommand(command) } } - const addRelevantContext = (ctx: Context) => { + const addRelevantContext = (ctx: ClientFileContext) => { if (chatRef.current) { chatRef.current.addRelevantContext(ctx) } else { @@ -100,7 +98,7 @@ export default function ChatPage() { } } - const updateActiveSelection = (ctx: Context | null) => { + const updateActiveSelection = (ctx: ClientFileContext | null) => { if (chatRef.current) { chatRef.current.updateActiveSelection(ctx) } else if (ctx) { @@ -123,8 +121,8 @@ export default function ChatPage() { useMacOSKeyboardEventHandler.current = request.useMacOSKeyboardEventHandler }, - sendMessage: (message: ChatMessage) => { - return sendMessage(message) + executeCommand: async (command: ChatCommand) => { + return executeCommand(command) }, showError: (errorMessage: ErrorMessage) => { setErrorMessage(errorMessage) @@ -132,7 +130,7 @@ export default function ChatPage() { cleanError: () => { setErrorMessage(null) }, - addRelevantContext: context => { + addRelevantContext: (context: ClientFileContext) => { return addRelevantContext(context) }, updateTheme: (style, themeClass) => { @@ -155,7 +153,9 @@ export default function ChatPage() { document.documentElement.className = themeClass + ` client client-${client}` }, - updateActiveSelection + updateActiveSelection: (context: ClientFileContext | null) => { + return updateActiveSelection(context) + } }) useEffect(() => { @@ -267,21 +267,32 @@ export default function ChatPage() { const clearPendingState = () => { setPendingRelevantContexts([]) - setPendingMessages([]) + setPendingCommand(undefined) setPendingActiveSelection(null) } const onChatLoaded = () => { - pendingRelevantContexts.forEach(addRelevantContext) - pendingMessages.forEach(sendMessage) - chatRef.current?.updateActiveSelection(pendingActiveSelection) + const currentChatRef = chatRef.current + if (!currentChatRef) return + + pendingRelevantContexts.forEach(context => { + currentChatRef.addRelevantContext(context) + }) + currentChatRef.updateActiveSelection(pendingActiveSelection) + if (pendingCommand) { + currentChatRef.executeCommand(pendingCommand) + } clearPendingState() setChatLoaded(true) } - const onNavigateToContext = (context: Context, opts?: NavigateOpts) => { - server?.navigate(context, opts) + const openInEditor = async (fileLocation: FileLocation) => { + return server?.openInEditor(fileLocation) ?? false + } + + const openExternal = async (url: string) => { + return server?.openExternal(url) } const refresh = async () => { @@ -385,11 +396,9 @@ export default function ChatPage() { key={activeChatId} ref={chatRef} chatInputRef={chatInputRef} - onNavigateToContext={onNavigateToContext} onLoaded={onChatLoaded} maxWidth={client === 'vscode' ? '5xl' : undefined} onCopyContent={isInEditor && server?.onCopy} - onSubmitMessage={isInEditor && server?.onSubmitMessage} onApplyInEditor={ isInEditor && (supportsOnApplyInEditorV2 @@ -401,7 +410,8 @@ export default function ChatPage() { isInEditor && (supportsOnLookupSymbol ? server?.lookupSymbol : undefined) } - openInEditor={isInEditor && server?.openInEditor} + openInEditor={openInEditor} + openExternal={openExternal} readWorkspaceGitRepositories={ isInEditor && supportsProvideWorkspaceGitRepoInfo ? server?.readWorkspaceGitRepositories diff --git a/ee/tabby-ui/app/files/components/chat-side-bar.tsx b/ee/tabby-ui/app/files/components/chat-side-bar.tsx index f8f673dfae95..cf565daef923 100644 --- a/ee/tabby-ui/app/files/components/chat-side-bar.tsx +++ b/ee/tabby-ui/app/files/components/chat-side-bar.tsx @@ -1,13 +1,13 @@ import React from 'react' import { find } from 'lodash-es' -import type { Context } from 'tabby-chat-panel' +import type { FileLocation } from 'tabby-chat-panel' import { useClient } from 'tabby-chat-panel/react' import { useLatest } from '@/lib/hooks/use-latest' import { useMe } from '@/lib/hooks/use-me' import { filename2prism } from '@/lib/language-utils' import { useChatStore } from '@/lib/stores/chat-store' -import { cn, formatLineHashForCodeBrowser } from '@/lib/utils' +import { cn, formatLineHashForLocation } from '@/lib/utils' import { Button } from '@/components/ui/button' import { IconClose } from '@/components/ui/icons' @@ -28,13 +28,14 @@ export const ChatSideBar: React.FC = ({ const activeChatId = useChatStore(state => state.activeChatId) const iframeRef = React.useRef(null) const repoMapRef = useLatest(repoMap) - const onNavigate = async (context: Context) => { - if (context?.filepath && context?.git_url) { - const lineHash = formatLineHashForCodeBrowser(context?.range) + const openInCodeBrowser = async (fileLocation: FileLocation) => { + const { filepath, location } = fileLocation + if (filepath.kind === 'git') { + const lineHash = formatLineHashForLocation(location) const repoMap = repoMapRef.current const matchedRepositoryKey = find( Object.keys(repoMap), - key => repoMap?.[key]?.gitUrl === context.git_url + key => repoMap?.[key]?.gitUrl === filepath.gitUrl ) if (matchedRepositoryKey) { const targetRepo = repoMap[matchedRepositoryKey] @@ -42,31 +43,24 @@ export const ChatSideBar: React.FC = ({ const defaultRef = getDefaultRepoRef(targetRepo.refs) // navigate to files of the default branch const refName = resolveRepoRef(defaultRef)?.name - const detectedLanguage = context.filepath - ? filename2prism(context.filepath)[0] - : undefined + const detectedLanguage = filename2prism(filepath.filepath)[0] const isMarkdown = detectedLanguage === 'markdown' updateActivePath( - generateEntryPath( - targetRepo, - refName, - context.filepath, - context.kind - ), + generateEntryPath(targetRepo, refName, filepath.filepath, 'file'), { hash: lineHash, replace: false, plain: isMarkdown && !!lineHash } ) - return + return true } } } + return false } const client = useClient(iframeRef, { - navigate: onNavigate, refresh: async () => { window.location.reload() @@ -75,33 +69,27 @@ export const ChatSideBar: React.FC = ({ setTimeout(() => resolve(null), 1000) }) }, - async onSubmitMessage(_msg, _relevantContext) {}, onApplyInEditor(_content) {}, onLoaded() {}, onCopy(_content) {}, onKeyboardEvent() {}, - async openInEditor() { - return false - } + openInEditor: async (fileLocation: FileLocation) => { + return openInCodeBrowser(fileLocation) + }, + openExternal: async (url: string) => { + window.open(url, '_blank') + }, }) - const getPrompt = ({ action }: QuickActionEventPayload) => { - let builtInPrompt = '' + const getCommand = ({ action }: QuickActionEventPayload) => { switch (action) { case 'explain': - builtInPrompt = 'Explain the selected code:' - break + return 'explain' case 'generate_unittest': - builtInPrompt = 'Generate a unit test for the selected code:' - break + return 'generate-tests' case 'generate_doc': - builtInPrompt = 'Generate documentation for the selected code:' - break - default: - break + return 'generate-docs' } - - return builtInPrompt } React.useEffect(() => { @@ -116,20 +104,27 @@ export const ChatSideBar: React.FC = ({ React.useEffect(() => { if (pendingEvent && client) { - const { lineFrom, lineTo, code, path, gitUrl } = pendingEvent - client.sendMessage({ - message: getPrompt(pendingEvent), - selectContext: { + const execute = async () => { + const { lineFrom, lineTo, code, path, gitUrl } = pendingEvent + client.updateActiveSelection({ kind: 'file', content: code, range: { start: lineFrom, end: lineTo ?? lineFrom }, - filepath: path, - git_url: gitUrl - } - }) + filepath: { + kind: 'git', + filepath: path, + gitUrl + } + }) + // FIXME: this delay is a workaround for waiting for the active selection to be updated + setTimeout(() => { + client.executeCommand(getCommand(pendingEvent)) + }, 100) + } + execute() } setPendingEvent(undefined) }, [pendingEvent, client]) diff --git a/ee/tabby-ui/app/search/components/assistant-message-section.tsx b/ee/tabby-ui/app/search/components/assistant-message-section.tsx index d4af5ab3a092..50ee5b31c05f 100644 --- a/ee/tabby-ui/app/search/components/assistant-message-section.tsx +++ b/ee/tabby-ui/app/search/components/assistant-message-section.tsx @@ -8,7 +8,6 @@ import { compact, isEmpty } from 'lodash-es' import { marked } from 'marked' import { useForm } from 'react-hook-form' import Textarea from 'react-textarea-autosize' -import { Context } from 'tabby-chat-panel/index' import * as z from 'zod' import { MARKDOWN_CITATION_REGEX } from '@/lib/constants/regex' @@ -20,6 +19,7 @@ import { import { makeFormErrorHandler } from '@/lib/tabby/gql' import { AttachmentDocItem, + Context, ExtendedCombinedError, RelevantCodeContext } from '@/lib/types' @@ -108,8 +108,7 @@ export function AssistantMessageSection({ onUpdateMessage } = useContext(SearchContext) - const { supportsOnApplyInEditorV2, onNavigateToContext } = - useContext(ChatContext) + const { supportsOnApplyInEditorV2 } = useContext(ChatContext) const [isEditing, setIsEditing] = useState(false) const [showMoreSource, setShowMoreSource] = useState(false) diff --git a/ee/tabby-ui/components/chat/chat-panel.tsx b/ee/tabby-ui/components/chat/chat-panel.tsx index 92d5a7746e7e..cf695881a1f3 100644 --- a/ee/tabby-ui/components/chat/chat-panel.tsx +++ b/ee/tabby-ui/components/chat/chat-panel.tsx @@ -5,7 +5,7 @@ import type { UseChatHelpers } from 'ai/react' import { AnimatePresence, motion } from 'framer-motion' import { compact } from 'lodash-es' import { toast } from 'sonner' -import type { Context } from 'tabby-chat-panel' +import type { Context } from '@/lib/types' import { SLUG_TITLE_MAX_LENGTH } from '@/lib/constants' import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index 360afcec4bc7..74b112dd76ef 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -1,13 +1,12 @@ import React, { RefObject } from 'react' import { compact, findIndex, isEqual, some, uniqWith } from 'lodash-es' import type { - Context, - FileContext, + ChatCommand, + ClientFileContext, FileLocation, GitRepository, LookupSymbolHint, - NavigateOpts, - SymbolInfo + SymbolInfo, } from 'tabby-chat-panel' import { useQuery } from 'urql' @@ -28,13 +27,22 @@ import { filename2prism } from '@/lib/language-utils' import { useChatStore } from '@/lib/stores/chat-store' import { ExtendedCombinedError } from '@/lib/types' import { + Context, + FileContext, AssistantMessage, MessageActionType, QuestionAnswerPair, UserMessage, UserMessageWithOptionalId } from '@/lib/types/chat' -import { cn, findClosestGitRepository, nanoid } from '@/lib/utils' +import { + cn, + findClosestGitRepository, + getPromptForChatCommand, + convertClientFileContext, + getFileLocationFromContext, + nanoid +} from '@/lib/utils' import { ChatPanel, ChatPanelRef } from './chat-panel' import { ChatScrollAnchor } from './chat-scroll-anchor' @@ -64,7 +72,6 @@ type ChatContextValue = { userMessageId: string, action: MessageActionType ) => void - onNavigateToContext?: (context: Context, opts?: NavigateOpts) => void onClearMessages: () => void container?: HTMLDivElement onCopyContent?: (value: string) => void @@ -75,7 +82,8 @@ type ChatContextValue = { symbol: string, hints?: LookupSymbolHint[] | undefined ) => Promise - openInEditor?: (target: FileLocation) => void + openInEditor: (target: FileLocation) => Promise + openExternal: (url: string) => Promise relevantContext: Context[] activeSelection: Context | null removeRelevantContext: (index: number) => void @@ -92,12 +100,12 @@ export const ChatContext = React.createContext( ) export interface ChatRef { - sendUserChat: (message: UserMessageWithOptionalId) => void + executeCommand: (command: ChatCommand) => Promise stop: () => void isLoading: boolean - addRelevantContext: (context: Context) => void + addRelevantContext: (context: ClientFileContext) => void focus: () => void - updateActiveSelection: (context: Context | null) => void + updateActiveSelection: (context: ClientFileContext | null) => void } interface ChatProps extends React.ComponentProps<'div'> { @@ -106,7 +114,6 @@ interface ChatProps extends React.ComponentProps<'div'> { initialMessages?: QuestionAnswerPair[] onLoaded?: () => void onThreadUpdates?: (messages: QuestionAnswerPair[]) => void - onNavigateToContext: (context: Context, opts?: NavigateOpts) => void container?: HTMLDivElement docQuery?: boolean generateRelevantQuestions?: boolean @@ -114,7 +121,6 @@ interface ChatProps extends React.ComponentProps<'div'> { welcomeMessage?: string promptFormClassname?: string onCopyContent?: (value: string) => void - onSubmitMessage?: (msg: string, relevantContext?: Context[]) => Promise onApplyInEditor?: | ((content: string) => void) | ((content: string, opts?: { languageId: string; smart: boolean }) => void) @@ -122,7 +128,8 @@ interface ChatProps extends React.ComponentProps<'div'> { symbol: string, hints?: LookupSymbolHint[] | undefined ) => Promise - openInEditor?: (target: FileLocation) => void + openInEditor: (target: FileLocation) => Promise + openExternal: (url: string) => Promise chatInputRef: RefObject supportsOnApplyInEditorV2: boolean readWorkspaceGitRepositories?: () => Promise @@ -135,7 +142,6 @@ function ChatRenderer( initialMessages, onLoaded, onThreadUpdates, - onNavigateToContext, container, docQuery, generateRelevantQuestions, @@ -143,10 +149,10 @@ function ChatRenderer( welcomeMessage, promptFormClassname, onCopyContent, - onSubmitMessage, onApplyInEditor, onLookupSymbol, openInEditor, + openExternal, chatInputRef, supportsOnApplyInEditorV2, readWorkspaceGitRepositories @@ -265,9 +271,7 @@ function ChatRenderer( setQaPairs(nextQaPairs) setInput(userMessage.message) if (userMessage.activeContext) { - onNavigateToContext(userMessage.activeContext, { - openInEditor: true - }) + openInEditor(getFileLocationFromContext(userMessage.activeContext)) } deleteThreadMessagePair(threadId, qaPair?.user.id, qaPair?.assistant?.id) @@ -483,15 +487,19 @@ function ChatRenderer( return handleSendUserChat.current?.(userMessage) } + const executeCommand = async (command: ChatCommand) => { + const prompt = getPromptForChatCommand(command) + sendUserChat({ + message: prompt, + selectContext: activeSelection ?? undefined + }) + } + const handleSubmit = async (value: string) => { - if (onSubmitMessage) { - onSubmitMessage(value, relevantContext) - } else { - sendUserChat({ - message: value, - relevantContext: relevantContext - }) - } + sendUserChat({ + message: value, + relevantContext: relevantContext + }) setRelevantContext([]) } @@ -499,7 +507,8 @@ function ChatRenderer( setRelevantContext(oldValue => appendContextAndDedupe(oldValue, context)) }) - const addRelevantContext = (context: Context) => { + const addRelevantContext = (clientFileContext: ClientFileContext) => { + const context = convertClientFileContext(clientFileContext) handleAddRelevantContext.current?.(context) } @@ -521,8 +530,9 @@ function ChatRenderer( 300 ) - const updateActiveSelection = (ctx: Context | null) => { - debouncedUpdateActiveSelection.run(ctx) + const updateActiveSelection = (clientFileContext: ClientFileContext | null) => { + const context = clientFileContext ? convertClientFileContext(clientFileContext) : null + debouncedUpdateActiveSelection.run(context) } const fetchWorkspaceGitRepo = () => { @@ -566,7 +576,7 @@ function ChatRenderer( ref, () => { return { - sendUserChat, + executeCommand, stop, isLoading, addRelevantContext, @@ -585,7 +595,6 @@ function ChatRenderer( threadId, isLoading, qaPairs, - onNavigateToContext, handleMessageAction, onClearMessages, container, @@ -593,6 +602,7 @@ function ChatRenderer( onApplyInEditor, onLookupSymbol, openInEditor, + openExternal, relevantContext, removeRelevantContext, chatInputRef, diff --git a/ee/tabby-ui/components/chat/question-answer.tsx b/ee/tabby-ui/components/chat/question-answer.tsx index f9dd02821ae9..9934459d9550 100644 --- a/ee/tabby-ui/components/chat/question-answer.tsx +++ b/ee/tabby-ui/components/chat/question-answer.tsx @@ -5,12 +5,12 @@ import React, { useMemo } from 'react' import Image from 'next/image' import tabbyLogo from '@/assets/tabby.png' import { compact, isEmpty, isEqual, isNil, uniqWith } from 'lodash-es' -import type { Context } from 'tabby-chat-panel' import { MARKDOWN_CITATION_REGEX } from '@/lib/constants/regex' import { useMe } from '@/lib/hooks/use-me' import { filename2prism } from '@/lib/language-utils' import { + Context, AssistantMessage, AttachmentCodeItem, QuestionAnswerPair, @@ -19,7 +19,9 @@ import { import { cn, getRangeFromAttachmentCode, - getRangeTextFromAttachmentCode + getRangeTextFromAttachmentCode, + getFileLocationFromContext, + buildCodeBrowserUrlForContext, } from '@/lib/utils' import { CopyButton } from '../copy-button' @@ -108,7 +110,7 @@ function UserMessageCard(props: { message: UserMessage }) { const { message } = props const [{ data }] = useMe() const selectContext = message.selectContext - const { onNavigateToContext, supportsOnApplyInEditorV2 } = + const { openInEditor, supportsOnApplyInEditorV2 } = React.useContext(ChatContext) const selectCodeSnippet = React.useMemo(() => { if (!selectContext?.content) return '' @@ -175,11 +177,10 @@ function UserMessageCard(props: { message: UserMessage }) { {selectCode && message.selectContext && (
- onNavigateToContext?.(message.selectContext!, { - openInEditor: true - }) - } + onClick={() => { + const context = message.selectContext! + openInEditor(getFileLocationFromContext(context)) + }} >

@@ -258,11 +259,11 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { ...rest } = props const { - onNavigateToContext, onApplyInEditor, onCopyContent, onLookupSymbol, openInEditor, + openExternal, supportsOnApplyInEditorV2 } = React.useContext(ChatContext) const [relevantCodeHighlightIndex, setRelevantCodeHighlightIndex] = @@ -334,6 +335,29 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { setRelevantCodeHighlightIndex(undefined) } + // When onApplyInEditor is null, it means isInEditor === false, thus there's no need to showExternalLink + const isInEditor = !!onApplyInEditor + + const onContextClick = (context: Context, isClient?: boolean) => { + // When isInEditor is false, we are in the code browser. + // The `openInEditor` function implementation as `openInCodeBrowser`, + // and will navigate to target without opening a new tab. + // So we use `openInEditor` here. + if (isClient || !isInEditor) { + openInEditor({ + filepath: { + kind: 'git', + filepath: context.filepath, + gitUrl: context.git_url + }, + location: context.range + }) + } else { + const url = buildCodeBrowserUrlForContext(window.location.href, context) + openExternal(url) + } + } + const onCodeCitationClick = (code: AttachmentCodeItem) => { const { startLine, endLine } = getRangeFromAttachmentCode(code) const ctx: Context = { @@ -346,9 +370,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { end: endLine } } - onNavigateToContext?.(ctx, { - openInEditor: code.isClient - }) + onContextClick(ctx, code.isClient) } return ( @@ -380,15 +402,10 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { { - onNavigateToContext?.(ctx, { - openInEditor: isInWorkspace - }) - }} - // When onApplyInEditor is null, it means isInEditor === false, thus there's no need to showExternalLink - showExternalLink={!!onApplyInEditor} - isInEditor={!!onApplyInEditor} - showClientCodeIcon={!onApplyInEditor} + onContextClick={onContextClick} + showExternalLink={isInEditor} + isInEditor={isInEditor} + showClientCodeIcon={!isInEditor} highlightIndex={relevantCodeHighlightIndex} triggerClassname="md:pt-0" /> diff --git a/ee/tabby-ui/components/message-markdown/index.tsx b/ee/tabby-ui/components/message-markdown/index.tsx index 935ed8bed849..3d55875d50e1 100644 --- a/ee/tabby-ui/components/message-markdown/index.tsx +++ b/ee/tabby-ui/components/message-markdown/index.tsx @@ -8,7 +8,7 @@ import { Maybe, MessageAttachmentClientCode } from '@/lib/gql/generates/graphql' -import { AttachmentCodeItem, AttachmentDocItem } from '@/lib/types' +import { AttachmentCodeItem, AttachmentDocItem, FileContext } from '@/lib/types' import { cn } from '@/lib/utils' import { HoverCard, @@ -20,7 +20,6 @@ import { MemoizedReactMarkdown } from '@/components/markdown' import './style.css' import { - FileContext, FileLocation, Filepath, LookupSymbolHint, diff --git a/ee/tabby-ui/components/message-markdown/markdown-context.tsx b/ee/tabby-ui/components/message-markdown/markdown-context.tsx index 8bb4005ae679..4527f6cb38bc 100644 --- a/ee/tabby-ui/components/message-markdown/markdown-context.tsx +++ b/ee/tabby-ui/components/message-markdown/markdown-context.tsx @@ -1,8 +1,8 @@ import { createContext } from 'react' -import { FileContext, FileLocation, SymbolInfo } from 'tabby-chat-panel/index' +import { FileLocation, SymbolInfo } from 'tabby-chat-panel/index' import { ContextInfo } from '@/lib/gql/generates/graphql' -import { AttachmentCodeItem } from '@/lib/types' +import { AttachmentCodeItem, FileContext } from '@/lib/types' export type MessageMarkdownContextValue = { onCopyContent?: ((value: string) => void) | undefined diff --git a/ee/tabby-ui/lib/types/chat.ts b/ee/tabby-ui/lib/types/chat.ts index a1c0caf291ac..645b0246be85 100644 --- a/ee/tabby-ui/lib/types/chat.ts +++ b/ee/tabby-ui/lib/types/chat.ts @@ -1,4 +1,3 @@ -import type { ChatMessage, Context } from 'tabby-chat-panel' import type { components } from 'tabby-openapi' import { @@ -10,8 +9,28 @@ import { } from '../gql/generates/graphql' import { ArrayElementType } from './common' -export interface UserMessage extends ChatMessage { +export interface FileContext { + kind: 'file' + filepath: string + range: { start: number; end: number } + content: string + git_url: string +} + +export type Context = FileContext + +export interface UserMessage { id: string + message: string + + // Client side context - displayed in user message + selectContext?: Context + + // Client side contexts - displayed in assistant message + relevantContext?: Array + + // Client side active selection context - displayed in assistant message + activeContext?: Context } export type UserMessageWithOptionalId = Omit & { diff --git a/ee/tabby-ui/lib/utils/index.ts b/ee/tabby-ui/lib/utils/index.ts index 218487694347..1b1b6c5b75f5 100644 --- a/ee/tabby-ui/lib/utils/index.ts +++ b/ee/tabby-ui/lib/utils/index.ts @@ -1,9 +1,19 @@ import { clsx, type ClassValue } from 'clsx' import { compact, isNil } from 'lodash-es' import { customAlphabet } from 'nanoid' +import type { + ChatCommand, + ClientFileContext, + FileLocation, + Filepath, + LineRange, + Location, + Position, + PositionRange +} from 'tabby-chat-panel' import { twMerge } from 'tailwind-merge' -import { AttachmentCodeItem, AttachmentDocItem } from '@/lib/types' +import { AttachmentCodeItem, AttachmentDocItem, FileContext } from '@/lib/types' import { Maybe } from '../gql/generates/graphql' @@ -107,6 +117,38 @@ export function formatLineHashForCodeBrowser( ).join('-') } +export function formatLineHashForLocation(location: Location | undefined) { + if (!location) { + return '' + } + if (typeof location === 'number') { + return `L${location}` + } + if ( + typeof location === 'object' && + 'line' in location && + typeof location.line === 'number' + ) { + return `L${location.line}` + } + if ('start' in location) { + const start = location.start + if (typeof start === 'number') { + const end = location.end as number + return `L${start}-${end}` + } + if ( + typeof start === 'object' && + 'line' in start && + typeof start.line === 'number' + ) { + const end = location.end as Position + return `L${start.line}-${end.line}` + } + } + return '' +} + export function getRangeFromAttachmentCode(code: { startLine?: Maybe content: string @@ -139,3 +181,97 @@ export function getContent(item: AttachmentDocItem) { return '' } + +export function getPromptForChatCommand(command: ChatCommand) { + switch (command) { + case 'explain': + return 'Explain the selected code:' + case 'fix': + return 'Identify and fix potential bugs in the selected code:' + case 'generate-docs': + return 'Generate documentation for the selected code:' + case 'generate-tests': + return 'Generate a unit test for the selected code:' + } +} + +export function convertClientFileContext( + clientFileContext: ClientFileContext +): FileContext { + const convertRange = (range: LineRange | PositionRange | undefined) => { + // FIXME: If the range is not provided, the whole file is considered. + if (!range) { + return { start: 0, end: 0 } + } + + if (typeof range.start === 'number') { + return range as LineRange + } + + const positionRange = range as PositionRange + return { + start: positionRange.start.line, + end: positionRange.end.line + } + } + + const convertFilepath = (filepath: Filepath) => { + if (filepath.kind === 'uri') { + return { + filepath: filepath.uri, + git_url: '' + } + } + + return { + filepath: filepath.filepath, + git_url: filepath.gitUrl + } + } + + return { + kind: 'file', + content: clientFileContext.content, + range: convertRange(clientFileContext.range), + ...convertFilepath(clientFileContext.filepath) + } +} + +export function getFilepathFromContext(context: FileContext): Filepath { + if (context.git_url.length > 1 && !context.filepath.includes(':')) { + return { + kind: 'git', + filepath: context.filepath, + gitUrl: context.git_url + } + } else { + return { + kind: 'uri', + uri: context.filepath + } + } +} + +export function getFileLocationFromContext(context: FileContext): FileLocation { + return { + filepath: getFilepathFromContext(context), + location: context.range + } +} + +export function buildCodeBrowserUrlForContext( + base: string, + context: FileContext +) { + const url = new URL(base) + url.pathname = '/files' + + const searchParams = new URLSearchParams() + searchParams.append('redirect_filepath', context.filepath) + searchParams.append('redirect_git_url', context.git_url) + url.search = searchParams.toString() + + url.hash = formatLineHashForCodeBrowser(context.range) + + return url.toString() +}