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

feat(tabby-ui): add mention functionality in tabby chat ui #3607

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
19 changes: 19 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,9 @@ export default function ChatPage() {
supportsReadWorkspaceGitRepoInfo,
setSupportsReadWorkspaceGitRepoInfo
] = useState(false)
const [supportsListFileInWorkspace, setSupportProvideFileAtInfo] =
useState(false)
const [supportsReadFileContent, setSupportsReadFileContent] = useState(false)

const executeCommand = (command: ChatCommand) => {
if (chatRef.current) {
Expand Down Expand Up @@ -244,6 +247,12 @@ export default function ChatPage() {
server
?.hasCapability('readWorkspaceGitRepositories')
.then(setSupportsReadWorkspaceGitRepoInfo)
server
?.hasCapability('listFileInWorkspace')
.then(setSupportProvideFileAtInfo)
server
?.hasCapability('readFileContent')
.then(setSupportsReadFileContent)
}

checkCapabilities().then(() => {
Expand Down Expand Up @@ -427,6 +436,16 @@ export default function ChatPage() {
: undefined
}
getActiveEditorSelection={getActiveEditorSelection}
listFileInWorkspace={
isInEditor && supportsListFileInWorkspace
? server?.listFileInWorkspace
: undefined
}
readFileContent={
isInEditor && supportsReadFileContent
? server?.readFileContent
: undefined
}
/>
</ErrorBoundary>
)
Expand Down
26 changes: 13 additions & 13 deletions ee/tabby-ui/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ import {
IconTrash
} from '@/components/ui/icons'
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
import { PromptForm, PromptFormRef } from '@/components/chat/prompt-form'
import { PromptForm } from '@/components/chat/prompt-form'
import { FooterText } from '@/components/footer'

import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { ChatContext } from './chat'
import { PromptFormRef } from './form-editor/types'
import { RepoSelect } from './repo-select'

export interface ChatPanelProps
Expand All @@ -51,14 +52,14 @@ export interface ChatPanelProps

export interface ChatPanelRef {
focus: () => void
setInput: (input: string) => void
input: string
}

function ChatPanelRenderer(
{
stop,
reload,
input,
setInput,
className,
onSubmit,
chatMaxWidthClass,
Expand Down Expand Up @@ -142,14 +143,17 @@ function ChatPanelRenderer(
chatInputRef.current?.focus()
})
}

React.useImperativeHandle(
ref,
() => {
return {
focus: () => {
promptFormRef.current?.focus()
}
},
setInput: str => {
promptFormRef.current?.setInput(str)
},
input: promptFormRef.current?.input ?? ''
}
},
[]
Expand Down Expand Up @@ -254,7 +258,7 @@ function ChatPanelRenderer(
ease: 'easeInOut',
duration: 0.1
}}
exit={{ opacity: 0, scale: 0.9, y: 5 }}
exit={{ opacity: 0, scale: 0.9, y: -5 }}
>
<Badge
variant="outline"
Expand All @@ -277,7 +281,7 @@ function ChatPanelRenderer(
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 rounded-l-none"
className="h-7 w-7 shrink-0 rounded-l-none hover:bg-muted/50"
onClick={e => {
updateEnableActiveSelection(!enableActiveSelection)
}}
Expand All @@ -299,7 +303,7 @@ function ChatPanelRenderer(
ease: 'easeInOut',
duration: 0.1
}}
exit={{ opacity: 0, scale: 0.9, y: 5 }}
exit={{ opacity: 0, scale: 0.9, y: -5 }}
layout
>
<Badge
Expand All @@ -310,7 +314,7 @@ function ChatPanelRenderer(
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 rounded-l-none"
className="h-7 w-7 shrink-0 rounded-l-none hover:bg-muted/50"
onClick={removeRelevantContext.bind(null, idx)}
>
<IconRemove />
Expand All @@ -324,11 +328,7 @@ function ChatPanelRenderer(
<PromptForm
ref={promptFormRef}
onSubmit={onSubmit}
input={input}
setInput={setInput}
isLoading={isLoading}
chatInputRef={chatInputRef}
isInitializing={!initialized}
/>
<FooterText className="hidden sm:block" />
</div>
Expand Down
45 changes: 42 additions & 3 deletions ee/tabby-ui/components/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import type {
EditorContext,
EditorFileContext,
FileLocation,
FileRange,
GitRepository,
ListFileItem,
ListFilesInWorkspaceParams,
LookupSymbolHint,
SymbolInfo
} from 'tabby-chat-panel'
Expand Down Expand Up @@ -48,6 +51,7 @@ import {
import { ChatPanel, ChatPanelRef } from './chat-panel'
import { ChatScrollAnchor } from './chat-scroll-anchor'
import { EmptyScreen } from './empty-screen'
import { FILEITEM_REGEX } from './form-editor/utils'
import { QuestionAnswerList } from './question-answer'

type ChatContextValue = {
Expand Down Expand Up @@ -80,6 +84,10 @@ type ChatContextValue = {
setSelectedRepoId: React.Dispatch<React.SetStateAction<string | undefined>>
repos: RepositorySourceListQuery['repositoryList'] | undefined
fetchingRepos: boolean
listFileInWorkspace?: (
params: ListFilesInWorkspaceParams
) => Promise<ListFileItem[]>
readFileContent?: (info: FileRange) => Promise<string | null>
}

export const ChatContext = React.createContext<ChatContextValue>(
Expand Down Expand Up @@ -121,6 +129,10 @@ interface ChatProps extends React.ComponentProps<'div'> {
supportsOnApplyInEditorV2: boolean
readWorkspaceGitRepositories?: () => Promise<GitRepository[]>
getActiveEditorSelection?: () => Promise<EditorFileContext | null>
listFileInWorkspace?: (
params: ListFilesInWorkspaceParams
) => Promise<ListFileItem[]>
readFileContent?: (info: FileRange) => Promise<string | null>
}

function ChatRenderer(
Expand All @@ -144,7 +156,9 @@ function ChatRenderer(
chatInputRef,
supportsOnApplyInEditorV2,
readWorkspaceGitRepositories,
getActiveEditorSelection
getActiveEditorSelection,
listFileInWorkspace,
readFileContent
}: ChatProps,
ref: React.ForwardedRef<ChatRef>
) {
Expand All @@ -153,7 +167,6 @@ function ChatRenderer(
const [threadId, setThreadId] = React.useState<string | undefined>()
const isOnLoadExecuted = React.useRef(false)
const [qaPairs, setQaPairs] = React.useState(initialMessages ?? [])
const [input, setInput] = React.useState<string>('')
const [relevantContext, setRelevantContext] = React.useState<Context[]>([])
const [activeSelection, setActiveSelection] = React.useState<Context | null>(
null
Expand All @@ -169,6 +182,12 @@ function ChatRenderer(

const chatPanelRef = React.useRef<ChatPanelRef>(null)

// both set/get input from prompt form
const setInput = (str: string) => {
chatPanelRef.current?.setInput(str)
}
const input = chatPanelRef.current?.input ?? ''

const [{ data: repositoryListData, fetching: fetchingRepos }] = useQuery({
query: repositorySourceListQuery
})
Expand Down Expand Up @@ -489,10 +508,28 @@ function ChatRenderer(
}

const handleSubmit = async (value: string) => {
const fileItems: any[] = []
let newValue = value

let match
while ((match = FILEITEM_REGEX.exec(value)) !== null) {
try {
const parsedItem = JSON.parse(match[1])
fileItems.push(parsedItem)

const replacement = `@${
parsedItem.label.split('/').pop() || parsedItem.label || 'unknown'
}`
newValue = newValue.replace(match[0], replacement)
} catch (error) {
continue
}
}
sendUserChat({
message: value,
relevantContext: relevantContext
})

setRelevantContext([])
}

Expand Down Expand Up @@ -620,7 +657,9 @@ function ChatRenderer(
setSelectedRepoId,
repos,
fetchingRepos,
initialized
initialized,
listFileInWorkspace,
readFileContent
}}
>
<div className="flex justify-center overflow-x-hidden">
Expand Down
72 changes: 72 additions & 0 deletions ee/tabby-ui/components/chat/form-editor/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* PromptProps defines the props for the PromptForm component.
*/
export interface PromptProps {
/**
* A callback function that handles form submission.
* It returns a Promise, so you can handle async actions.
*/
onSubmit: (value: string) => Promise<void>
/**
* Indicates if the form (or chat) is in a loading/submitting state.
*/
isLoading: boolean
}

/**
* PromptFormRef defines the methods exposed by the PromptForm via forwardRef.
*/
export interface PromptFormRef {
/**
* Focus the editor inside PromptForm.
*/
focus: () => void
/**
* Set the content of the editor programmatically.
*/
setInput: (value: string) => void
/**
* Get the current editor text content.
*/
input: string
}

/**
* Represents a file item inside the workspace.
* (You can add more properties if needed)
*/
export interface FileItem {
label: string
id?: string
// ... any other fields that you might have
}

/**
* Represents a file source item for mention suggestions.
*/
export interface SourceItem {
name: string
filepath: string
category: 'file'
fileItem: FileItem
}

/**
* The attributes stored in a mention node.
*/
export interface MentionNodeAttrs {
id: string
name: string
category: 'file'
fileItem: FileItem
}

/**
* Stores the current state of the mention feature while typing.
*/
export interface MentionState {
items: SourceItem[]
command: ((props: MentionNodeAttrs) => void) | null
query: string
selectedIndex: number
}
Loading
Loading