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

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
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
15 changes: 8 additions & 7 deletions ee/tabby-ui/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { RefObject, useMemo, useState } from 'react'
import slugify from '@sindresorhus/slugify'
import { Editor } from '@tiptap/core'
import { useWindowSize } from '@uidotdev/usehooks'
import type { UseChatHelpers } from 'ai/react'
import { AnimatePresence, motion } from 'framer-motion'
Expand Down Expand Up @@ -47,14 +48,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 @@ -138,14 +139,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 @@ -319,10 +323,7 @@ function ChatPanelRenderer(
<PromptForm
ref={promptFormRef}
onSubmit={onSubmit}
input={input}
setInput={setInput}
isLoading={isLoading}
chatInputRef={chatInputRef}
isInitializing={!initialized}
/>
<FooterText className="hidden sm:block" />
Expand Down
41 changes: 36 additions & 5 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 @@ -39,6 +41,10 @@ import { cn, findClosestGitRepository, nanoid } from '@/lib/utils'
import { ChatPanel, ChatPanelRef } from './chat-panel'
import { ChatScrollAnchor } from './chat-scroll-anchor'
import { EmptyScreen } from './empty-screen'
import {
extractAtSourceFromString,
isFileAtInfo
} from './prompt-form-editor/utils'
import { QuestionAnswerList } from './question-answer'

const repositoryListQuery = graphql(/* GraphQL */ `
Expand Down Expand Up @@ -85,6 +91,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 +134,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,15 +159,16 @@ function ChatRenderer(
openInEditor,
chatInputRef,
supportsOnApplyInEditorV2,
readWorkspaceGitRepositories
readWorkspaceGitRepositories,
provideFileAtInfo,
getFileAtInfoContent
}: ChatProps,
ref: React.ForwardedRef<ChatRef>
) {
const [initialized, setInitialized] = React.useState(false)
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 @@ -173,6 +184,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: repositoryListQuery
})
Expand Down Expand Up @@ -484,11 +501,23 @@ function ChatRenderer(
}

const handleSubmit = async (value: string) => {
const { text, atInfos } = extractAtSourceFromString(value)

// TODO: handle @{AtInfos} here into
atInfos.forEach(async atInfo => {
if (isFileAtInfo(atInfo)) {
const res = await getFileAtInfoContent?.(atInfo)
console.log('file at info content:', res)
} else {
console.log('symbol at info:', atInfo)
}
})

if (onSubmitMessage) {
onSubmitMessage(value, relevantContext)
onSubmitMessage(text, relevantContext)
} else {
sendUserChat({
message: value,
message: text,
relevantContext: relevantContext
})
}
Expand Down Expand Up @@ -602,7 +631,9 @@ function ChatRenderer(
setSelectedRepoId,
repos,
fetchingRepos,
initialized
initialized,
provideFileAtInfo,
getFileAtInfoContent
}}
>
<div className="flex justify-center overflow-x-hidden">
Expand Down
130 changes: 130 additions & 0 deletions ee/tabby-ui/components/chat/popover-mention-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React, { useEffect, useRef } from 'react'

import { MentionNodeAttrs, SourceItem } from './prompt-form-editor/types'

interface PopoverMentionListProps {
items: SourceItem[]
selectedIndex: number
onUpdateSelectedIndex: (index: number) => void
handleItemSelection: (
item: SourceItem,
command?: (props: MentionNodeAttrs) => void
) => void
}

// Maximum number of items visible in the list
const MAX_VISIBLE_ITEMS = 4
// Height of each item in pixels
const ITEM_HEIGHT = 42

export const PopoverMentionList: React.FC<PopoverMentionListProps> = ({
items,
selectedIndex,
onUpdateSelectedIndex,
handleItemSelection
}) => {
const selectedItemRef = useRef<HTMLButtonElement>(null)
const containerRef = useRef<HTMLDivElement>(null)

// Dynamically calculate container height based on number of items
const containerHeight =
Math.min(items.length, MAX_VISIBLE_ITEMS) * ITEM_HEIGHT

// Scroll into view for the currently selected item
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])

// Render list content
const renderContent = () => {
if (!items.length) {
return (
<div className="text-muted-foreground/70 flex h-full items-center justify-center px-3 py-2.5 text-sm">
No files found
</div>
)
}

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

return (
<button
key={filepath + '-' + item.name}
ref={isSelected ? selectedItemRef : null}
onClick={() => {
handleItemSelection(item)
}}
onMouseEnter={() => {
onUpdateSelectedIndex(index)
}}
onMouseDown={e => e.preventDefault()}
type="button"
tabIndex={-1}
style={{ height: `${ITEM_HEIGHT}px` }}
className={`flex w-full shrink-0 items-center justify-between rounded-sm px-3 text-sm transition-colors
${
isSelected
? 'bg-accent/50 text-accent-foreground'
: 'hover:bg-accent/50'
}
group relative`}
>
<div className="flex min-w-0 max-w-[60%] items-center gap-2.5">
<svg
className={`h-3.5 w-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="truncate font-medium">{item.name}</span>
</div>
<span
className={`max-w-[40%] truncate text-[11px] ${
isSelected
? 'text-accent-foreground/90'
: 'text-muted-foreground/60 group-hover:text-accent-foreground/80'
}`}
>
{filepath}
</span>
</button>
)
})}
</div>
)
}

return <>{renderContent()}</>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-disable no-console */
// mention-component.tsx
import React from 'react'
import { NodeViewWrapper } from '@tiptap/react'

import { cn } from '@/lib/utils'

export const PromptFormMentionComponent = ({ node }: { node: any }) => {
console.log('mention comp:' + JSON.stringify(node.attrs))
return (
<NodeViewWrapper className="source-mention">
<span
className={cn(
'inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-sm font-medium',
'ring-1 ring-inset ring-muted'
)}
data-category={node.attrs.category}
>
{node.attrs.category === 'file' ? (
<svg
className="mr-1 h-3 w-3 text-muted-foreground"
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="mr-1">@</span>
)}
<span>{node.attrs.name}</span>
</span>
</NodeViewWrapper>
)
}
Loading