Skip to content

Commit

Permalink
feat(chat): add mention functionality with category selection and sty…
Browse files Browse the repository at this point in the history
…ling
  • Loading branch information
Sma1lboy committed Dec 21, 2024
1 parent c78539d commit 0d0c81a
Show file tree
Hide file tree
Showing 12 changed files with 1,264 additions and 453 deletions.
112 changes: 112 additions & 0 deletions clients/vscode/src/chat/WebviewHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
vscodeRangeToChatPanelPositionRange,
chatPanelLocationToVSCodeRange,
} from "./utils";
import path from "path";

export class WebviewHelper {
webview?: Webview;
Expand Down Expand Up @@ -728,6 +729,117 @@ export class WebviewHelper {
}
return infoList;
},
provideSymbolAtInfo: async (opts?: AtInputOpts): Promise<SymbolAtInfo[] | null> => {
const maxResults = opts?.limit || 50;
const query = opts?.query?.toLowerCase();

const editor = window.activeTextEditor;
if (!editor) return null;
const document = editor.document;

// Try document symbols first
const documentSymbols = await commands.executeCommand<DocumentSymbol[] | SymbolInformation[]>(
"vscode.executeDocumentSymbolProvider",
document.uri,
);

let results: SymbolAtInfo[] = [];

if (documentSymbols && documentSymbols.length > 0) {
const processSymbol = (symbol: DocumentSymbol | SymbolInformation) => {
if (results.length >= maxResults) return;

const symbolName = symbol.name.toLowerCase();
if (query && !symbolName.includes(query)) return;

if (getAllowedSymbolKinds().includes(symbol.kind)) {
results.push(vscodeSymbolToSymbolAtInfo(symbol, document.uri, this.gitProvider));
}
if (isDocumentSymbol(symbol)) {
symbol.children.forEach(processSymbol);
}
};
documentSymbols.forEach(processSymbol);
}

// Try workspace symbols if no document symbols found
if (results.length === 0 && query) {
const workspaceSymbols = await commands.executeCommand<SymbolInformation[]>(
"vscode.executeWorkspaceSymbolProvider",
query,
);

if (workspaceSymbols) {
results = workspaceSymbols
.filter((symbol) => getAllowedSymbolKinds().includes(symbol.kind))
.slice(0, maxResults)
.map((symbol) => vscodeSymbolToSymbolAtInfo(symbol, symbol.location.uri, this.gitProvider));
}
}

return results.length > 0 ? results : null;
},

provideFileAtInfo: async (opts?: AtInputOpts): Promise<FileAtInfo[] | null> => {
const maxResults = opts?.limit || 50;
const query = opts?.query;

const globPattern = "**/*";
const excludePattern = "**/node_modules/**";
try {
const files = await workspace.findFiles(globPattern, excludePattern);

const filteredFiles = query
? files.filter((uri) => {
const a = path.basename(uri.fsPath);
const b = query.toLowerCase();
this.logger.info("uri:" + a + " " + "query:" + b + " result:" + a.toLowerCase().startsWith(b));
this.logger.info(b);
return a.toLowerCase().startsWith(b);
})
: files;

const sortedFiles = filteredFiles.sort((a, b) => {
const nameA = a.fsPath.toLowerCase();
const nameB = b.fsPath.toLowerCase();
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0;
});

const limitedFiles = sortedFiles.slice(0, maxResults);

return limitedFiles.map((uri) => uriToFileAtInfo(uri, this.gitProvider));
} catch (error) {
this.logger.error("Failed to find files:", error);
return null;
}
},
getSymbolAtInfoContent: async (info: SymbolAtInfo): Promise<string | null> => {
try {
const uri = chatPanelFilepathToLocalUri(info.location.filepath, this.gitProvider);
if (!uri) return null;

const document = await workspace.openTextDocument(uri);
const range = chatPanelLocationToVSCodeRange(info.location.location);
if (!range) return null;

return document.getText(range);
} catch (error) {
this.logger.error("Failed to get symbol content:", error);
return null;
}
},
getFileAtInfoContent: async (info: FileAtInfo): Promise<string | null> => {
try {
const uri = chatPanelFilepathToLocalUri(info.filepath, this.gitProvider);
if (!uri) return null;

const document = await workspace.openTextDocument(uri);
return document.getText();
} catch (error) {
this.logger.error("Failed to get file content:", error);
return null;
}
},
});
}
}
20 changes: 20 additions & 0 deletions ee/tabby-ui/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export default function ChatPage() {
supportsProvideWorkspaceGitRepoInfo,
setSupportsProvideWorkspaceGitRepoInfo
] = useState(false)
const [supportProvideFileAtInfo, setSupportProvideFileAtInfo] =
useState(false)
const [supportGetFileAtInfoContent, setSupportGetFileAtInfoContent] =
useState(false)

const sendMessage = (message: ChatMessage) => {
if (chatRef.current) {
Expand Down Expand Up @@ -245,6 +249,12 @@ export default function ChatPage() {
server
?.hasCapability('readWorkspaceGitRepositories')
.then(setSupportsProvideWorkspaceGitRepoInfo)
server
?.hasCapability('provideFileAtInfo')
.then(setSupportProvideFileAtInfo)
server
?.hasCapability('getFileAtInfoContent')
.then(setSupportGetFileAtInfoContent)
}

checkCapabilities()
Expand Down Expand Up @@ -407,6 +417,16 @@ export default function ChatPage() {
? server?.readWorkspaceGitRepositories
: undefined
}
provideFileAtInfo={
isInEditor && supportProvideFileAtInfo
? server?.provideFileAtInfo
: undefined
}
getFileAtInfoContent={
isInEditor && supportGetFileAtInfoContent
? server?.getFileAtInfoContent
: undefined
}
/>
</ErrorBoundary>
)
Expand Down
141 changes: 141 additions & 0 deletions ee/tabby-ui/components/chat/FileList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/* eslint-disable no-console */
import React, { useEffect, useRef } from 'react'

import { SuggestionItem } from './types'

interface FileListProps {
items: SuggestionItem[]
selectedIndex: number
onSelect: (item: {
id: string
label: string
category: 'file' | 'symbol'
}) => void
onUpdateSelectedIndex: (index: number) => void
}

const MAX_VISIBLE_ITEMS = 4
const ITEM_HEIGHT = 42 // px

export const FileList: React.FC<FileListProps> = ({
items,
selectedIndex,
onSelect,
onUpdateSelectedIndex
}) => {
console.log('[FileList] Rendering with items:', items.length)
console.log('[FileList] Selected index:', selectedIndex)

const selectedItemRef = useRef<HTMLButtonElement>(null)
const containerRef = useRef<HTMLDivElement>(null)

const containerHeight =
Math.min(items.length, MAX_VISIBLE_ITEMS) * ITEM_HEIGHT

useEffect(() => {
const container = containerRef.current
const selectedItem = selectedItemRef.current
if (container && selectedItem) {
const containerTop = container.scrollTop
const containerBottom = containerTop + container.clientHeight
const itemTop = selectedItem.offsetTop
const itemBottom = itemTop + selectedItem.offsetHeight

if (itemTop < containerTop) {
container.scrollTop = itemTop
} else if (itemBottom > containerBottom) {
container.scrollTop = itemBottom - container.clientHeight
}
}
}, [selectedIndex])

const renderContent = () => {
if (!items.length) {
console.log('[FileList] No items to display')
return (
<div className="h-full flex items-center justify-center px-3 py-2.5 text-sm text-muted-foreground/70">
No files found
</div>
)
}

return (
<div
ref={containerRef}
className="flex flex-col w-full divide-y divide-border/30 overflow-y-auto"
style={{
maxHeight: `${MAX_VISIBLE_ITEMS * ITEM_HEIGHT}px`,
height: `${containerHeight}px`
}}
>
{items.map((item, index) => {
console.log(`[FileList] Rendering item: ${item.label}`)
const filepath =
'filepath' in item.filepath
? item.filepath.filepath
: item.filepath.uri
const isSelected = index === selectedIndex

return (
<button
key={filepath}
ref={isSelected ? selectedItemRef : null}
onClick={() => {
console.log(`[FileList] Item selected: ${item.label}`)
onSelect({
id: item.id,
label: item.label,
category: item.category
})
}}
onMouseEnter={() => {
console.log(`[FileList] Mouse enter on item: ${item.label}`)
onUpdateSelectedIndex(index)
}}
onMouseDown={e => e.preventDefault()}
type="button"
tabIndex={-1}
style={{ height: `${ITEM_HEIGHT}px` }}
className={`flex items-center justify-between w-full px-3 text-sm rounded-sm transition-colors flex-shrink-0
${
isSelected
? 'bg-accent/50 text-accent-foreground'
: 'hover:bg-accent/50'
}
group relative`}
>
<div className="flex items-center gap-2.5 min-w-0 max-w-[60%]">
<svg
className={`w-3.5 h-3.5 shrink-0 ${
isSelected
? 'text-accent-foreground'
: 'text-muted-foreground/70 group-hover:text-accent-foreground/90'
}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
<span className="font-medium truncate">{item.label}</span>
</div>
<span
className={`text-[11px] truncate max-w-[40%] ${
isSelected
? 'text-accent-foreground/90'
: 'text-muted-foreground/60 group-hover:text-accent-foreground/80'
}`}
>
{filepath}
</span>
</button>
)
})}
</div>
)
}

return <>{renderContent()}</>
}
14 changes: 12 additions & 2 deletions ee/tabby-ui/components/chat/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { RefObject } from 'react'
import { compact, findIndex, isEqual, some, uniqWith } from 'lodash-es'
import type {
AtInputOpts,
Context,
FileAtInfo,
FileContext,
FileLocation,
GitRepository,
Expand Down Expand Up @@ -85,6 +87,8 @@ type ChatContextValue = {
setSelectedRepoId: React.Dispatch<React.SetStateAction<string | undefined>>
repos: RepositorySourceListQuery['repositoryList'] | undefined
fetchingRepos: boolean
provideFileAtInfo?: (opts?: AtInputOpts) => Promise<FileAtInfo[] | null>
getFileAtInfoContent?: (info: FileAtInfo) => Promise<string | null>
}

export const ChatContext = React.createContext<ChatContextValue>(
Expand Down Expand Up @@ -126,6 +130,8 @@ interface ChatProps extends React.ComponentProps<'div'> {
chatInputRef: RefObject<HTMLTextAreaElement>
supportsOnApplyInEditorV2: boolean
readWorkspaceGitRepositories?: () => Promise<GitRepository[]>
provideFileAtInfo?: (opts?: AtInputOpts) => Promise<FileAtInfo[] | null>
getFileAtInfoContent?: (info: FileAtInfo) => Promise<string | null>
}

function ChatRenderer(
Expand All @@ -149,7 +155,9 @@ function ChatRenderer(
openInEditor,
chatInputRef,
supportsOnApplyInEditorV2,
readWorkspaceGitRepositories
readWorkspaceGitRepositories,
provideFileAtInfo,
getFileAtInfoContent
}: ChatProps,
ref: React.ForwardedRef<ChatRef>
) {
Expand Down Expand Up @@ -602,7 +610,9 @@ function ChatRenderer(
setSelectedRepoId,
repos,
fetchingRepos,
initialized
initialized,
provideFileAtInfo,
getFileAtInfoContent
}}
>
<div className="flex justify-center overflow-x-hidden">
Expand Down
Loading

0 comments on commit 0d0c81a

Please sign in to comment.