Skip to content

Commit

Permalink
chore(ui): optimize styling of chat mention tags (#3758)
Browse files Browse the repository at this point in the history
* chore(ui): optimize styling of chat mention tags

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
liangfung and autofix-ci[bot] authored Jan 26, 2025
1 parent ae5aae3 commit de45082
Showing 6 changed files with 195 additions and 182 deletions.
85 changes: 38 additions & 47 deletions ee/tabby-ui/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
@@ -287,55 +287,46 @@ function ChatPanelRenderer(
className="border-t bg-background px-4 py-2 shadow-lg sm:space-y-4 sm:rounded-t-xl sm:border md:py-4"
>
<div className="flex flex-wrap gap-2">
<AnimatePresence presenceAffectsLayout>
<RepoSelect
value={selectedRepoId}
onChange={onSelectRepo}
repos={repos}
isInitializing={!initialized}
/>
{activeSelection ? (
<motion.div
key="active-selection"
initial={{ opacity: 0, scale: 0.9, y: -5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{
ease: 'easeInOut',
duration: 0.1
<RepoSelect
id="repo-select"
value={selectedRepoId}
onChange={onSelectRepo}
repos={repos}
isInitializing={!initialized}
/>
{activeSelection ? (
<Badge
id="active-selection-badge"
variant="outline"
className={cn(
'inline-flex h-7 flex-nowrap items-center gap-1.5 overflow-hidden rounded-md pr-0 text-sm font-semibold',
{
'border-dashed !text-muted-foreground italic line-through':
!enableActiveSelection
}
)}
>
<IconFileText />
<ContextLabel
context={activeSelection}
className="flex-1 truncate"
/>
<span className="shrink-0 text-muted-foreground">
Current file
</span>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 rounded-l-none hover:bg-muted/50"
onClick={e => {
updateEnableActiveSelection(!enableActiveSelection)
}}
exit={{ opacity: 0, scale: 0.9, y: -5 }}
>
<Badge
variant="outline"
className={cn(
'inline-flex h-7 flex-nowrap items-center gap-1.5 overflow-hidden rounded-md pr-0 text-sm font-semibold',
{
'border-dashed !text-muted-foreground italic line-through':
!enableActiveSelection
}
)}
>
<IconFileText />
<ContextLabel
context={activeSelection}
className="flex-1 truncate"
/>
<span className="shrink-0 text-muted-foreground">
Current file
</span>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 rounded-l-none hover:bg-muted/50"
onClick={e => {
updateEnableActiveSelection(!enableActiveSelection)
}}
>
{enableActiveSelection ? <IconEye /> : <IconEyeOff />}
</Button>
</Badge>
</motion.div>
) : null}
{enableActiveSelection ? <IconEye /> : <IconEyeOff />}
</Button>
</Badge>
) : null}
<AnimatePresence>
{relevantContext.map((item, idx) => {
// `git_url + filepath + range` as unique key
const key = `${item.git_url}_${item.filepath}_${item.range?.start}_${item.range?.end}`
30 changes: 17 additions & 13 deletions ee/tabby-ui/components/chat/form-editor/mention.tsx
Original file line number Diff line number Diff line change
@@ -4,12 +4,14 @@ import {
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import Mention from '@tiptap/extension-mention'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'
import { uniqBy } from 'lodash-es'
import {
Filepath,
ListFileItem,
@@ -21,7 +23,7 @@ import { IconFile } from '@/components/ui/icons'

import { emitter } from '../event-emitter'
import type { SourceItem } from './types'
import { fileItemToSourceItem, shortenLabel } from './utils'
import { fileItemToSourceItem } from './utils'

/**
* A React component to render a mention node in the editor.
@@ -41,17 +43,15 @@ export const MentionComponent = ({ node }: { node: any }) => {
}, [])

return (
<NodeViewWrapper className="-my-1 inline-block align-middle">
<NodeViewWrapper as="span" className="rounded-sm px-1">
<span
className={cn(
'prose inline-flex items-center gap-0.5 rounded bg-muted px-1.5 py-0.5 text-sm font-medium text-foreground',
'ring-1 ring-inset ring-muted',
'relative top-[0.1em]'
'space-x-0.5 whitespace-nowrap rounded bg-muted px-1.5 py-0.5 align-middle text-sm font-medium text-foreground'
)}
data-category={node.attrs.category}
>
<IconFile />
<span className="relative top-[-0.5px]">
<IconFile className="relative -top-px inline-block h-3.5 w-3.5" />
<span className="relative whitespace-normal">
{resolveFileNameForDisplay(filepathString)}
</span>
</span>
@@ -161,7 +161,7 @@ export const MentionList = forwardRef<MentionListActions, MentionListProps>(
if (!listFileInWorkspace) return []
const files = await listFileInWorkspace({ query })
const result = files?.map(fileItemToSourceItem) || []
setItems(result)
setItems(uniqBy(result, 'id'))
}
fetchOptions()
}, [query])
@@ -205,7 +205,7 @@ export const MentionList = forwardRef<MentionListActions, MentionListProps>(
key={`${JSON.stringify(filepath)}`}
onClick={() => handleSelectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
title={item.name}
title={item.filepath}
data={item}
isSelected={index === selectedIndex}
/>
@@ -226,6 +226,10 @@ interface OptionItemView extends HTMLAttributes<HTMLDivElement> {
}
function OptionItemView({ isSelected, data, ...rest }: OptionItemView) {
const ref = useRef<HTMLDivElement>(null)
const filepathWithoutFilename = useMemo(() => {
return data.filepath.split('/').slice(0, -1).join('/')
}, [data.filepath])

useLayoutEffect(() => {
if (isSelected && ref.current) {
ref.current?.scrollIntoView({
@@ -238,7 +242,7 @@ function OptionItemView({ isSelected, data, ...rest }: OptionItemView) {
return (
<div
className={cn(
'flex cursor-pointer flex-nowrap items-center gap-1 rounded-md px-2 py-1.5 text-sm',
'flex cursor-pointer flex-nowrap items-center gap-1 overflow-hidden rounded-md px-2 py-1.5 text-sm',
{
'bg-accent text-accent-foreground': isSelected
}
@@ -249,9 +253,9 @@ function OptionItemView({ isSelected, data, ...rest }: OptionItemView) {
<span className="flex h-5 shrink-0 items-center">
<IconFile />
</span>
<span className="flex-1 truncate">{shortenLabel(data.name)}</span>
<span className="max-w-[150px] truncate text-xs text-muted-foreground">
{shortenLabel(data.filepath, 20)}
<span className="mr-2 truncate whitespace-nowrap">{data.name}</span>
<span className="flex-1 truncate text-xs text-muted-foreground">
{filepathWithoutFilename}
</span>
</div>
)
1 change: 1 addition & 0 deletions ee/tabby-ui/components/chat/form-editor/types.ts
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@ export type FileItem = ListFileItem
* Represents a file source item for the mention suggestion list.
*/
export interface SourceItem {
id: string
name: string
filepath: string
category: 'file'
15 changes: 13 additions & 2 deletions ee/tabby-ui/components/chat/form-editor/utils.tsx
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import { Filepath } from 'tabby-chat-panel/index'

import { PLACEHOLDER_FILE_REGEX } from '@/lib/constants/regex'
import { FileContext } from '@/lib/types'
import { convertFilepath, resolveFileNameForDisplay } from '@/lib/utils'
import { convertFilepath, nanoid, resolveFileNameForDisplay } from '@/lib/utils'

import { FileItem, SourceItem } from './types'

@@ -13,12 +13,23 @@ import { FileItem, SourceItem } from './types'
*/
export function fileItemToSourceItem(info: FileItem): SourceItem {
const filepathString = convertFilepath(info.filepath).filepath
return {
const source: Omit<SourceItem, 'id'> = {
fileItem: info,
name: resolveFileNameForDisplay(filepathString), // Extract the last segment of the path as the name
filepath: filepathString,
category: 'file'
}
try {
return {
id: JSON.stringify(info.filepath),
...source
}
} catch (e) {
return {
id: nanoid(),
...source
}
}
}

/**
4 changes: 2 additions & 2 deletions ee/tabby-ui/components/chat/prompt-form.tsx
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ import {
import './prompt-form.css'

import { EditorState } from '@tiptap/pm/state'
import { isEqual } from 'lodash-es'
import { isEqual, uniqBy } from 'lodash-es'
import { EditorFileContext } from 'tabby-chat-panel/index'
import tippy, { GetReferenceClientRect, Instance } from 'tippy.js'

@@ -112,7 +112,7 @@ function PromptFormRenderer(
items: async ({ query }) => {
if (!listFileInWorkspace) return []
const files = await listFileInWorkspace({ query })
return files?.map(fileItemToSourceItem) || []
return uniqBy(files?.map(fileItemToSourceItem) || [], 'id')
},
render: () => {
let component: ReactRenderer<MentionListActions, MentionListProps>
242 changes: 124 additions & 118 deletions ee/tabby-ui/components/chat/repo-select.tsx
Original file line number Diff line number Diff line change
@@ -28,7 +28,8 @@ import LoadingWrapper from '@/components/loading-wrapper'

import { SourceIcon } from '../source-icon'

interface RepoSelectProps {
interface RepoSelectProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
repos: RepositorySourceListQuery['repositoryList'] | undefined
value: string | undefined
onChange: (v: string | undefined) => void
@@ -39,7 +40,8 @@ export function RepoSelect({
repos,
value,
onChange,
isInitializing
isInitializing,
...rest
}: RepoSelectProps) {
const [open, setOpen] = useState(false)
const commandListRef = useRef<HTMLDivElement>(null)
@@ -69,125 +71,129 @@ export function RepoSelect({
if (!isInitializing && !repos?.length) return null

return (
<LoadingWrapper
loading={isInitializing}
fallback={
<div className="w-full pl-2">
<Skeleton className="h-3 w-[10rem]" />
</div>
}
>
<Popover open={open} onOpenChange={setOpen}>
<Badge
variant="outline"
className={cn(
'h-7 items-center gap-1 overflow-hidden break-all rounded-md px-0 text-sm font-semibold hover:bg-muted/50',
{
'border-dashed text-muted-foreground italic': !selectedRepo
}
)}
>
<PopoverTrigger className="outline-none" asChild>
<div
className={cn(
'flex flex-1 cursor-pointer items-center gap-1.5 overflow-hidden pl-2.5',
{
'min-w-[10rem]': !selectedRepo
}
)}
>
{selectedRepo ? (
<SourceIcon
kind={selectedRepo.sourceKind}
className="h-3.5 w-3.5 shrink-0"
/>
) : (
<IconFolderGit className="shrink-0" />
)}
<div className="flex flex-1 items-center gap-1.5 truncate break-all">
<span
className={cn('truncate', {
'text-muted-foreground': !selectedRepoName
})}
>
{selectedRepoName || 'Workspace'}
</span>
</div>
{!value && (
<div className="flex h-7 w-7 shrink-0 items-center justify-center text-foreground">
<IconChevronUpDown />
<div {...rest}>
<LoadingWrapper
loading={isInitializing}
fallback={
<div className="w-full pl-2">
<Skeleton className="h-3 w-[10rem]" />
</div>
}
>
<Popover open={open} onOpenChange={setOpen}>
<Badge
variant="outline"
className={cn(
'h-7 items-center gap-1 overflow-hidden break-all rounded-md px-0 text-sm font-semibold hover:bg-muted/50',
{
'border-dashed text-muted-foreground italic': !selectedRepo
}
)}
>
<PopoverTrigger className="outline-none" asChild>
<div
className={cn(
'flex flex-1 cursor-pointer items-center gap-1.5 overflow-hidden pl-2.5',
{
'min-w-[10rem]': !selectedRepo
}
)}
>
{selectedRepo ? (
<SourceIcon
kind={selectedRepo.sourceKind}
className="h-3.5 w-3.5 shrink-0"
/>
) : (
<IconFolderGit className="shrink-0" />
)}
<div className="flex flex-1 items-center gap-1.5 truncate break-all">
<span
className={cn('truncate', {
'text-muted-foreground': !selectedRepoName
})}
>
{selectedRepoName || 'Workspace'}
</span>
</div>
)}
</div>
</PopoverTrigger>
{!!value && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 rounded-l-none bg-background hover:bg-muted/50"
onClick={e => {
e.stopPropagation()
onChange(undefined)
}}
>
<IconRemove />
</Button>
)}
</Badge>
<PopoverContent
side="top"
align="start"
className="dropdown-menu w-[80vw] overflow-x-hidden rounded-md border bg-popover p-2 text-popover-foreground shadow animate-in"
>
<Command>
<CommandInput
placeholder="Search context..."
onValueChange={onSearchChange}
/>
<CommandList className="max-h-[30vh]" ref={commandListRef}>
<CommandEmpty>No context found</CommandEmpty>
<CommandGroup>
{repos?.map(repo => {
const isSelected = repo.sourceId === value
{!value && (
<div className="flex h-7 w-7 shrink-0 items-center justify-center text-foreground">
<IconChevronUpDown />
</div>
)}
</div>
</PopoverTrigger>
{!!value && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 rounded-l-none bg-background hover:bg-muted/50"
onClick={e => {
e.stopPropagation()
onChange(undefined)
}}
>
<IconRemove />
</Button>
)}
</Badge>
<PopoverContent
side="top"
align="start"
className="dropdown-menu w-[80vw] overflow-x-hidden rounded-md border bg-popover p-2 text-popover-foreground shadow animate-in"
>
<Command>
<CommandInput
placeholder="Search context..."
onValueChange={onSearchChange}
/>
<CommandList className="max-h-[30vh]" ref={commandListRef}>
<CommandEmpty>No context found</CommandEmpty>
<CommandGroup>
{repos?.map(repo => {
const isSelected = repo.sourceId === value

return (
<CommandItem
key={repo.sourceId}
onSelect={() => {
onSelectRepo(repo.sourceId)
setOpen(false)
}}
title={repo.sourceName}
className="cursor-pointer"
>
<IconCheck
className={cn(
'mr-1 shrink-0',
repo.sourceId === value ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="flex flex-1 items-center gap-1 overflow-x-hidden">
<SourceIcon
kind={repo.sourceKind}
className="shrink-0"
return (
<CommandItem
key={repo.sourceId}
onSelect={() => {
onSelectRepo(repo.sourceId)
setOpen(false)
}}
title={repo.sourceName}
className="cursor-pointer"
>
<IconCheck
className={cn(
'mr-1 shrink-0',
repo.sourceId === value
? 'opacity-100'
: 'opacity-0'
)}
/>
<div
className={cn('truncate font-medium', {
'font-semibold': isSelected
})}
>
{repo.sourceName}
<div className="flex flex-1 items-center gap-1 overflow-x-hidden">
<SourceIcon
kind={repo.sourceKind}
className="shrink-0"
/>
<div
className={cn('truncate font-medium', {
'font-semibold': isSelected
})}
>
{repo.sourceName}
</div>
</div>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</LoadingWrapper>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</LoadingWrapper>
</div>
)
}

0 comments on commit de45082

Please sign in to comment.