mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(kb-connectors): align connector UI surfaces (#4728)
This commit is contained in:
@@ -14,7 +14,7 @@ interface FooterItem {
|
||||
}
|
||||
|
||||
const PRODUCT_LINKS: FooterItem[] = [
|
||||
{ label: 'Mothership', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Mothership', href: 'https://docs.sim.ai/mothership', external: true },
|
||||
{ label: 'Workflows', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
|
||||
{ label: 'Tables', href: 'https://docs.sim.ai/tables', external: true },
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Database, DatabaseX } from '@/components/emcn/icons'
|
||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
|
||||
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
@@ -920,19 +921,35 @@ export function KnowledgeBase({
|
||||
const def = CONNECTOR_REGISTRY[connector.connectorType]
|
||||
const ConnectorIcon = def?.icon
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={connector.id}
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowConnectorsModal(true)}
|
||||
className='flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
className='h-7 max-w-[180px] shrink-0 justify-start gap-1.5 rounded-lg border border-[var(--border-muted)] bg-[var(--surface-2)] px-2 text-[var(--text-secondary)] text-caption hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{connector.status === 'syncing' ? (
|
||||
<Loader className='size-[14px]' animate />
|
||||
) : (
|
||||
ConnectorIcon && <ConnectorIcon className='size-[14px]' />
|
||||
)}
|
||||
{def?.name || connector.connectorType}
|
||||
</button>
|
||||
<span className='relative flex size-4 flex-shrink-0 items-center justify-center'>
|
||||
{connector.status === 'syncing' ? (
|
||||
<Loader className='size-[14px]' animate />
|
||||
) : (
|
||||
ConnectorIcon && <ConnectorIcon className='size-[14px]' />
|
||||
)}
|
||||
{connector.status !== 'active' && connector.status !== 'syncing' && (
|
||||
<span
|
||||
className={cn(
|
||||
'-right-0.5 -top-0.5 absolute size-1.5 rounded-xs border border-[var(--surface-2)]',
|
||||
connector.status === 'error'
|
||||
? 'bg-[var(--text-error)]'
|
||||
: connector.status === 'disabled'
|
||||
? 'bg-[var(--caution)]'
|
||||
: 'bg-[var(--text-muted)]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className='truncate'>{def?.name || connector.connectorType}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
@@ -1317,6 +1334,7 @@ export function KnowledgeBase({
|
||||
connectors={connectors}
|
||||
isLoading={isLoadingConnectors}
|
||||
canEdit={userPermissions.canEdit}
|
||||
className='mt-0'
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
|
||||
+59
-57
@@ -240,23 +240,23 @@ export function AddConnectorModal({
|
||||
: `Configure the ${connectorConfig?.name} connector settings`}
|
||||
</ModalDescription>
|
||||
|
||||
<ModalBody>
|
||||
<ModalBody className='pb-3'>
|
||||
{step === 'select-type' ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex min-h-0 flex-col gap-2'>
|
||||
<div className='flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-2 py-[5px] transition-colors duration-100 hover-hover:border-[var(--border-1)] hover-hover:bg-[var(--surface-3)]'>
|
||||
<Search
|
||||
className='size-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search sources...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='min-h-[400px] overflow-y-auto'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='min-h-[400px] overflow-y-auto [scrollbar-gutter:stable]'>
|
||||
<div className='flex flex-col gap-0.5 pr-1'>
|
||||
{filteredEntries.map(([type, config]) => (
|
||||
<ConnectorTypeCard
|
||||
key={type}
|
||||
@@ -265,7 +265,7 @@ export function AddConnectorModal({
|
||||
/>
|
||||
))}
|
||||
{filteredEntries.length === 0 && (
|
||||
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
|
||||
<div className='rounded-lg bg-[var(--surface-3)] px-3 py-8 text-center text-[var(--text-muted)] text-small'>
|
||||
{CONNECTOR_ENTRIES.length === 0
|
||||
? 'No connectors available.'
|
||||
: `No sources found matching "${searchTerm}"`}
|
||||
@@ -276,7 +276,6 @@ export function AddConnectorModal({
|
||||
</div>
|
||||
) : connectorConfig ? (
|
||||
<div className='flex flex-col gap-3'>
|
||||
{/* Auth: API key input or OAuth credential selection */}
|
||||
{isApiKeyMode ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>
|
||||
@@ -336,7 +335,6 @@ export function AddConnectorModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config fields */}
|
||||
{connectorConfig.configFields.map((field) => {
|
||||
if (!isFieldVisible(field)) return null
|
||||
|
||||
@@ -357,13 +355,14 @@ export function AddConnectorModal({
|
||||
{field.description && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type='button'
|
||||
className='flex size-[14px] cursor-help items-center justify-center text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
|
||||
variant='ghost'
|
||||
className='flex size-[14px] cursor-help items-center justify-center p-0 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
|
||||
aria-label={`About ${field.title}`}
|
||||
>
|
||||
<Info className='size-[12px]' />
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -372,13 +371,14 @@ export function AddConnectorModal({
|
||||
{hasCanonicalPair && canonicalId && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type='button'
|
||||
className='flex size-[18px] items-center justify-center rounded-[3px] text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-secondary)]'
|
||||
variant='ghost'
|
||||
className='flex size-[18px] items-center justify-center rounded-[3px] p-0 text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-secondary)]'
|
||||
onClick={() => toggleCanonicalMode(canonicalId)}
|
||||
>
|
||||
<ArrowLeftRight className='size-[12px]' />
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{field.mode === 'basic'
|
||||
@@ -429,48 +429,50 @@ export function AddConnectorModal({
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tag definitions (opt-out) */}
|
||||
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Metadata Tags</Label>
|
||||
{connectorConfig.tagDefinitions.map((tagDef) => (
|
||||
<div
|
||||
key={tagDef.id}
|
||||
role='checkbox'
|
||||
aria-checked={!disabledTagIds.has(tagDef.id)}
|
||||
tabIndex={0}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-sm p-0.5 text-small'
|
||||
onClick={() => toggleTagDefinition(tagDef.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
handleKeyboardActivation(event, () => toggleTagDefinition(tagDef.id))
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={!disabledTagIds.has(tagDef.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => {
|
||||
setDisabledTagIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.delete(tagDef.id)
|
||||
} else {
|
||||
next.add(tagDef.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
<div className='flex flex-col gap-0.5 rounded-lg border border-[var(--border-muted)] bg-[var(--surface-2)] p-1'>
|
||||
{connectorConfig.tagDefinitions.map((tagDef) => (
|
||||
<div
|
||||
key={tagDef.id}
|
||||
role='checkbox'
|
||||
aria-checked={!disabledTagIds.has(tagDef.id)}
|
||||
tabIndex={0}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-small transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
onClick={() => toggleTagDefinition(tagDef.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
handleKeyboardActivation(event, () => toggleTagDefinition(tagDef.id))
|
||||
}}
|
||||
/>
|
||||
<span className='text-[var(--text-primary)]'>{tagDef.displayName}</span>
|
||||
<span className='text-[var(--text-muted)] text-xs'>
|
||||
({tagDef.fieldType})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
<Checkbox
|
||||
checked={!disabledTagIds.has(tagDef.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => {
|
||||
setDisabledTagIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.delete(tagDef.id)
|
||||
} else {
|
||||
next.add(tagDef.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
|
||||
{tagDef.displayName}
|
||||
</span>
|
||||
<span className='flex-shrink-0 text-[var(--text-muted)] text-xs'>
|
||||
{tagDef.fieldType}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync interval */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Sync Frequency</Label>
|
||||
<ButtonGroup
|
||||
@@ -550,14 +552,14 @@ function ConnectorTypeCard({ config, onClick }: ConnectorTypeCardProps) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
className='group flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className='size-[18px] flex-shrink-0' />
|
||||
<div className='flex min-w-0 flex-col gap-[1px]'>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
|
||||
{config.name}
|
||||
</span>
|
||||
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-lg bg-[var(--surface-4)] transition-colors group-hover:bg-[var(--surface-5)]'>
|
||||
<Icon className='size-[18px] text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-[1px]'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>{config.name}</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-xs'>{config.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
+5
-1
@@ -72,7 +72,7 @@ export function ConnectorSelectorField({
|
||||
|
||||
if (isLoading && isEnabled) {
|
||||
return (
|
||||
<div className='flex items-center gap-2 rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 text-[var(--text-muted)] text-sm'>
|
||||
<div className='flex items-center gap-2 rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 text-[var(--text-muted)] text-caption'>
|
||||
<Loader className='size-3.5' animate />
|
||||
Loading…
|
||||
</div>
|
||||
@@ -84,6 +84,7 @@ export function ConnectorSelectorField({
|
||||
return (
|
||||
<Combobox
|
||||
multiSelect
|
||||
size='sm'
|
||||
options={comboboxOptions}
|
||||
multiSelectValues={multiValues}
|
||||
onMultiSelectChange={(values) => onChange(values)}
|
||||
@@ -97,6 +98,7 @@ export function ConnectorSelectorField({
|
||||
: field.placeholder || `Select ${field.title.toLowerCase()}`
|
||||
}
|
||||
disabled={disabled || !credentialId || !depsResolved}
|
||||
emptyMessage={`No ${field.title.toLowerCase()} found`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -104,6 +106,7 @@ export function ConnectorSelectorField({
|
||||
const singleValue = Array.isArray(value) ? value[0] : value
|
||||
return (
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={comboboxOptions}
|
||||
value={singleValue || undefined}
|
||||
onChange={(next) => onChange(next)}
|
||||
@@ -117,6 +120,7 @@ export function ConnectorSelectorField({
|
||||
: field.placeholder || `Select ${field.title.toLowerCase()}`
|
||||
}
|
||||
disabled={disabled || !credentialId || !depsResolved}
|
||||
emptyMessage={`No ${field.title.toLowerCase()} found`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+78
-56
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { format, formatDistanceToNow, isPast } from 'date-fns'
|
||||
import {
|
||||
@@ -58,6 +58,7 @@ interface ConnectorsSectionProps {
|
||||
connectors: ConnectorData[]
|
||||
isLoading: boolean
|
||||
canEdit: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 5-minute cooldown after a manual sync trigger */
|
||||
@@ -68,19 +69,24 @@ const STATUS_CONFIG = {
|
||||
syncing: { label: 'Syncing', variant: 'amber' as const },
|
||||
error: { label: 'Error', variant: 'red' as const },
|
||||
paused: { label: 'Paused', variant: 'gray' as const },
|
||||
disabled: { label: 'Disabled', variant: 'amber' as const },
|
||||
disabled: { label: 'Disabled', variant: 'orange' as const },
|
||||
} as const
|
||||
|
||||
const CONNECTOR_ACTION_BUTTON_CLASSES =
|
||||
'size-7 rounded-lg p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
|
||||
|
||||
export function ConnectorsSection({
|
||||
workspaceId,
|
||||
knowledgeBaseId,
|
||||
connectors,
|
||||
isLoading,
|
||||
canEdit,
|
||||
className,
|
||||
}: ConnectorsSectionProps) {
|
||||
const { mutate: triggerSync } = useTriggerSync()
|
||||
const { mutate: updateConnector } = useUpdateConnector()
|
||||
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
|
||||
const deleteDocumentsId = useId()
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const [deleteDocuments, setDeleteDocuments] = useState(false)
|
||||
|
||||
@@ -184,16 +190,16 @@ export function ConnectorsSection({
|
||||
if (connectors.length === 0 && !canEdit && !isLoading) return null
|
||||
|
||||
return (
|
||||
<div className='mt-4'>
|
||||
<div className={cn('mt-4', className)}>
|
||||
{error && <p className='mt-2 text-[var(--text-error)] text-caption leading-tight'>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<div className='mt-2 flex flex-col gap-2'>
|
||||
<div className='mt-2 flex flex-col gap-1'>
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className='rounded-lg border border-[var(--border-1)] px-3 py-2.5'>
|
||||
<div key={i} className='rounded-lg px-2 py-2'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<Skeleton className='size-5 flex-shrink-0 rounded-sm' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Skeleton className='size-9 flex-shrink-0 rounded-lg' />
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[18px] w-[52px] rounded-full' />
|
||||
@@ -209,7 +215,7 @@ export function ConnectorsSection({
|
||||
No connected sources yet. Connect an external source to automatically sync documents.
|
||||
</p>
|
||||
) : (
|
||||
<div className='mt-2 flex flex-col gap-2'>
|
||||
<div className='-mx-2 mt-2 flex flex-col gap-0.5'>
|
||||
{connectors.map((connector) => (
|
||||
<ConnectorCard
|
||||
key={connector.id}
|
||||
@@ -242,19 +248,19 @@ export function ConnectorsSection({
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Remove Connector</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalDescription className='text-[var(--text-secondary)] text-sm'>
|
||||
<ModalDescription className='text-[var(--text-secondary)] text-small'>
|
||||
This will disconnect the source and stop future syncs. Documents already synced will
|
||||
remain in the knowledge base unless you choose to delete them.
|
||||
</ModalDescription>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='delete-docs'
|
||||
id={deleteDocumentsId}
|
||||
checked={deleteDocuments}
|
||||
onCheckedChange={(checked) => setDeleteDocuments(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor='delete-docs'
|
||||
className='cursor-pointer text-[var(--text-secondary)] text-sm'
|
||||
htmlFor={deleteDocumentsId}
|
||||
className='cursor-pointer text-[var(--text-secondary)] text-small'
|
||||
>
|
||||
Also delete all synced documents
|
||||
</label>
|
||||
@@ -355,28 +361,35 @@ function ConnectorCard({
|
||||
const syncLogs = detail?.syncLogs ?? []
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border border-[var(--border-1)]'>
|
||||
<div className='flex items-center justify-between px-3 py-2.5'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<div className='relative flex-shrink-0'>
|
||||
{Icon && <Icon className='size-5' />}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border border-transparent transition-colors duration-100',
|
||||
expanded
|
||||
? 'border-[var(--border-muted)] bg-[var(--surface-2)]'
|
||||
: 'hover-hover:bg-[var(--surface-active)]'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2 px-2 py-2'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
<div className='relative flex size-9 flex-shrink-0 items-center justify-center rounded-lg bg-[var(--surface-4)]'>
|
||||
{Icon && <Icon className='size-5 text-[var(--text-secondary)]' />}
|
||||
{connector.status === 'disabled' && (
|
||||
<AlertTriangle className='-right-1 -top-1 absolute size-3 text-amber-500' />
|
||||
<AlertTriangle className='-right-0.5 -top-0.5 absolute size-3 text-[var(--caution)]' />
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
|
||||
{connectorDef?.name || connector.connectorType}
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<span className='flex min-w-0 items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
|
||||
<span className='truncate'>{connectorDef?.name || connector.connectorType}</span>
|
||||
{(isSyncPending || connector.status === 'syncing') && (
|
||||
<Loader className='size-3 text-[var(--text-muted)]' animate />
|
||||
)}
|
||||
</span>
|
||||
<Badge variant={statusConfig.variant} className='text-micro'>
|
||||
<Badge variant={statusConfig.variant} size='sm' dot className='flex-shrink-0'>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5 text-[var(--text-muted)] text-xs'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[var(--text-muted)] text-xs'>
|
||||
{connector.lastSyncAt && (
|
||||
<span>Last sync: {format(new Date(connector.lastSyncAt), 'MMM d, h:mm a')}</span>
|
||||
)}
|
||||
@@ -409,14 +422,14 @@ function ConnectorCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex flex-shrink-0 items-center gap-0.5'>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='size-7 p-0'
|
||||
className={CONNECTOR_ACTION_BUTTON_CLASSES}
|
||||
onClick={onSync}
|
||||
disabled={
|
||||
connector.status === 'syncing' ||
|
||||
@@ -426,10 +439,7 @@ function ConnectorCard({
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'h-3.5 w-3.5',
|
||||
connector.status === 'syncing' && 'animate-spin'
|
||||
)}
|
||||
className={cn('size-3.5', connector.status === 'syncing' && 'animate-spin')}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
@@ -440,7 +450,11 @@ function ConnectorCard({
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' className='size-7 p-0' onClick={onEdit}>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={CONNECTOR_ACTION_BUTTON_CLASSES}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Settings className='size-3.5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
@@ -451,7 +465,7 @@ function ConnectorCard({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='size-7 p-0'
|
||||
className={CONNECTOR_ACTION_BUTTON_CLASSES}
|
||||
onClick={onTogglePause}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
@@ -473,7 +487,11 @@ function ConnectorCard({
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' className='size-7 p-0' onClick={onDelete}>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={CONNECTOR_ACTION_BUTTON_CLASSES}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash className='size-3.5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
@@ -486,11 +504,11 @@ function ConnectorCard({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='size-7 p-0'
|
||||
className={CONNECTOR_ACTION_BUTTON_CLASSES}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-3.5 w-3.5 transition-transform', expanded && 'rotate-180')}
|
||||
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
@@ -500,13 +518,13 @@ function ConnectorCard({
|
||||
</div>
|
||||
|
||||
{connector.status === 'disabled' && (
|
||||
<div className='border-[var(--border-1)] border-t px-3 py-2'>
|
||||
<div className='flex flex-col gap-1 rounded-sm border border-amber-200 bg-amber-50 px-2 py-1.5 dark:border-amber-900 dark:bg-amber-950'>
|
||||
<div className='flex items-center gap-1.5 font-medium text-amber-800 text-caption dark:text-amber-200'>
|
||||
<AlertTriangle className='size-3 flex-shrink-0' />
|
||||
<div className='border-[var(--border-muted)] border-t px-2 py-2'>
|
||||
<div className='flex flex-col gap-2 rounded-md border border-[var(--border-muted)] bg-[var(--surface-3)] px-2.5 py-2'>
|
||||
<div className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-caption'>
|
||||
<AlertTriangle className='size-3 flex-shrink-0 text-[var(--caution)]' />
|
||||
Connector disabled after repeated sync failures
|
||||
</div>
|
||||
<p className='text-amber-700 text-micro dark:text-amber-300'>
|
||||
<p className='text-[var(--text-muted)] text-caption leading-snug'>
|
||||
Syncing has been paused due to {connector.consecutiveFailures} consecutive failures.
|
||||
{serviceId
|
||||
? ' Reconnect your account to resume syncing.'
|
||||
@@ -529,7 +547,8 @@ function ConnectorCard({
|
||||
}
|
||||
setShowOAuthModal(true)
|
||||
}}
|
||||
className='w-full px-2 py-1 font-medium text-caption'
|
||||
size='sm'
|
||||
className='w-full'
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
@@ -539,10 +558,10 @@ function ConnectorCard({
|
||||
)}
|
||||
|
||||
{missingScopes.length > 0 && connector.status !== 'disabled' && (
|
||||
<div className='border-[var(--border-1)] border-t px-3 py-2'>
|
||||
<div className='flex flex-col gap-1 rounded-sm border bg-[var(--surface-2)] px-2 py-1.5'>
|
||||
<div className='flex items-center font-medium text-caption'>
|
||||
<span className='mr-1.5 inline-block size-[6px] rounded-xs bg-amber-500' />
|
||||
<div className='border-[var(--border-muted)] border-t px-2 py-2'>
|
||||
<div className='flex flex-col gap-2 rounded-md border border-[var(--border-muted)] bg-[var(--surface-3)] px-2.5 py-2'>
|
||||
<div className='flex items-center font-medium text-[var(--text-primary)] text-caption'>
|
||||
<span className='mr-1.5 inline-block size-[6px] rounded-xs bg-[var(--caution)]' />
|
||||
Additional permissions required
|
||||
</div>
|
||||
{canEdit && (
|
||||
@@ -562,7 +581,8 @@ function ConnectorCard({
|
||||
}
|
||||
setShowOAuthModal(true)
|
||||
}}
|
||||
className='w-full px-2 py-1 font-medium text-caption'
|
||||
size='sm'
|
||||
className='w-full'
|
||||
>
|
||||
Update access
|
||||
</Button>
|
||||
@@ -572,7 +592,7 @@ function ConnectorCard({
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className='border-[var(--border-1)] border-t px-3 py-2'>
|
||||
<div className='border-[var(--border-muted)] border-t px-2 py-2'>
|
||||
<SyncHistory logs={syncLogs} isLoading={detailLoading} />
|
||||
</div>
|
||||
)}
|
||||
@@ -620,7 +640,7 @@ interface SyncHistoryProps {
|
||||
function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center gap-2 py-1 text-[var(--text-muted)] text-xs'>
|
||||
<div className='flex items-center gap-2 rounded-md bg-[var(--surface-3)] px-2 py-2 text-[var(--text-muted)] text-xs'>
|
||||
<Loader className='size-3' animate />
|
||||
Loading sync history…
|
||||
</div>
|
||||
@@ -628,11 +648,15 @@ function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return <p className='py-1 text-[var(--text-muted)] text-xs'>No sync history yet.</p>
|
||||
return (
|
||||
<p className='rounded-md bg-[var(--surface-3)] px-2 py-2 text-[var(--text-muted)] text-xs'>
|
||||
No sync history yet.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{logs.map((log) => {
|
||||
const isError = log.status === 'error' || log.status === 'failed'
|
||||
const isRunning = log.status === 'running' || log.status === 'syncing'
|
||||
@@ -640,14 +664,14 @@ function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
|
||||
log.docsAdded + log.docsUpdated + log.docsDeleted + (log.docsFailed ?? 0)
|
||||
|
||||
return (
|
||||
<div key={log.id} className='flex items-start gap-2 text-xs'>
|
||||
<div key={log.id} className='flex items-start gap-2 rounded-md px-2 py-1.5 text-xs'>
|
||||
<div className='mt-[1px] flex-shrink-0'>
|
||||
{isRunning ? (
|
||||
<Loader className='size-3 text-[var(--text-muted)]' animate />
|
||||
) : isError ? (
|
||||
<XCircle className='size-3 text-[var(--text-error)]' />
|
||||
) : (
|
||||
<CheckCircle2 className='size-3 text-[var(--color-green-600)]' />
|
||||
<CheckCircle2 className='size-3 text-[var(--success)]' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -661,14 +685,12 @@ function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
|
||||
{totalChanges > 0 ? (
|
||||
<>
|
||||
{log.docsAdded > 0 && (
|
||||
<span className='text-[var(--color-green-600)]'>+{log.docsAdded}</span>
|
||||
<span className='text-[var(--success)]'>+{log.docsAdded}</span>
|
||||
)}
|
||||
{log.docsUpdated > 0 && (
|
||||
<>
|
||||
{log.docsAdded > 0 && ' '}
|
||||
<span className='text-[var(--color-amber-600)]'>
|
||||
~{log.docsUpdated}
|
||||
</span>
|
||||
<span className='text-[var(--caution)]'>~{log.docsUpdated}</span>
|
||||
</>
|
||||
)}
|
||||
{log.docsDeleted > 0 && (
|
||||
|
||||
+35
-24
@@ -295,7 +295,7 @@ export function EditConnectorModal({
|
||||
<ModalTabsTrigger value='documents'>Documents</ModalTabsTrigger>
|
||||
</ModalTabsList>
|
||||
|
||||
<ModalBody>
|
||||
<ModalBody className='pb-3'>
|
||||
<ModalTabsContent value='settings'>
|
||||
<SettingsTab
|
||||
connectorConfig={connectorConfig}
|
||||
@@ -393,13 +393,14 @@ function SettingsTab({
|
||||
{field.description && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type='button'
|
||||
className='flex size-[14px] cursor-help items-center justify-center text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
|
||||
variant='ghost'
|
||||
className='flex size-[14px] cursor-help items-center justify-center p-0 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
|
||||
aria-label={`About ${field.title}`}
|
||||
>
|
||||
<Info className='size-[12px]' />
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -408,13 +409,14 @@ function SettingsTab({
|
||||
{hasCanonicalPair && canonicalId && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type='button'
|
||||
className='flex size-[18px] items-center justify-center rounded-[3px] text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-secondary)]'
|
||||
variant='ghost'
|
||||
className='flex size-[18px] items-center justify-center rounded-[3px] p-0 text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-secondary)]'
|
||||
onClick={() => onToggleCanonicalMode(canonicalId)}
|
||||
>
|
||||
<ArrowLeftRight className='size-[12px]' />
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'}
|
||||
@@ -512,46 +514,55 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Skeleton className='h-6 w-full' />
|
||||
<Skeleton className='h-6 w-full' />
|
||||
<Skeleton className='h-6 w-full' />
|
||||
<Skeleton className='h-7 w-[180px] rounded-md' />
|
||||
<Skeleton className='h-9 w-full rounded-lg' />
|
||||
<Skeleton className='h-9 w-full rounded-lg' />
|
||||
<Skeleton className='h-9 w-full rounded-lg' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<ButtonGroup value={filter} onValueChange={(val) => setFilter(val as 'active' | 'excluded')}>
|
||||
<ButtonGroupItem value='active'>Active ({counts.active})</ButtonGroupItem>
|
||||
<ButtonGroupItem value='excluded'>Excluded ({counts.excluded})</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
|
||||
<div className='max-h-[320px] min-h-0 overflow-y-auto'>
|
||||
<div className='max-h-[320px] min-h-0 overflow-y-auto [scrollbar-gutter:stable]'>
|
||||
{documents.length === 0 ? (
|
||||
<p className='py-4 text-center text-[var(--text-muted)] text-small'>
|
||||
<p className='rounded-lg bg-[var(--surface-3)] px-3 py-8 text-center text-[var(--text-muted)] text-small'>
|
||||
{filter === 'excluded' ? 'No excluded documents' : 'No documents yet'}
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-0.5 pr-1'>
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.id} className='flex items-center justify-between'>
|
||||
<div
|
||||
key={doc.id}
|
||||
className='flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
<span className='truncate text-[var(--text-primary)] text-small'>
|
||||
{doc.filename}
|
||||
</span>
|
||||
{doc.sourceUrl && (
|
||||
<a
|
||||
href={doc.sourceUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex-shrink-0 text-[var(--text-muted)] hover-hover:text-[var(--text-secondary)]'
|
||||
>
|
||||
<ExternalLink className='size-3' />
|
||||
</a>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={doc.sourceUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex size-5 flex-shrink-0 items-center justify-center rounded-md text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<ExternalLink className='size-3' />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Open source document</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
variant='ghost-secondary'
|
||||
size='sm'
|
||||
className='flex-shrink-0'
|
||||
disabled={doc.userExcluded ? isRestoring : isExcluding}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { formatAbsoluteDate, formatRelativeTime } from '@sim/utils/formatting'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
|
||||
@@ -102,6 +102,19 @@ export function BaseCard({
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const connectorEntries = useMemo(
|
||||
() =>
|
||||
connectorTypes
|
||||
.map((type) => ({ type, config: CONNECTOR_REGISTRY[type] }))
|
||||
.filter((entry) => Boolean(entry.config?.icon)),
|
||||
[connectorTypes]
|
||||
)
|
||||
const visibleConnectorEntries = useMemo(() => connectorEntries.slice(0, 3), [connectorEntries])
|
||||
const hiddenConnectorLabels = useMemo(
|
||||
() => connectorEntries.slice(3).map(({ type, config }) => config?.name ?? type),
|
||||
[connectorEntries]
|
||||
)
|
||||
const hiddenConnectorCount = hiddenConnectorLabels.length
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
kbName: title,
|
||||
@@ -209,19 +222,14 @@ export function BaseCard({
|
||||
<p className='line-clamp-2 h-[36px] flex-1 text-[var(--text-tertiary)] text-caption leading-[18px]'>
|
||||
{description}
|
||||
</p>
|
||||
{connectorTypes.length > 0 && (
|
||||
<div className='flex flex-shrink-0 items-center'>
|
||||
{connectorTypes.map((type, index) => {
|
||||
const config = CONNECTOR_REGISTRY[type]
|
||||
if (!config?.icon) return null
|
||||
{connectorEntries.length > 0 && (
|
||||
<div className='[&>*:not(:first-child)]:-ml-1 flex flex-shrink-0 items-center'>
|
||||
{visibleConnectorEntries.map(({ type, config }) => {
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<Tooltip.Root key={type}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className='flex size-[20px] flex-shrink-0 items-center justify-center rounded-sm bg-[var(--surface-5)]'
|
||||
style={{ marginLeft: index > 0 ? '-4px' : '0' }}
|
||||
>
|
||||
<div className='flex size-5 flex-shrink-0 items-center justify-center rounded-md border border-[var(--surface-3)] bg-[var(--surface-5)] transition-[border-color] group-hover:border-[var(--surface-4)] dark:border-[var(--surface-4)] dark:group-hover:border-[var(--surface-5)]'>
|
||||
<Icon className='size-[12px] text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
@@ -229,6 +237,16 @@ export function BaseCard({
|
||||
</Tooltip.Root>
|
||||
)
|
||||
})}
|
||||
{hiddenConnectorCount > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='flex size-5 flex-shrink-0 items-center justify-center rounded-md border border-[var(--surface-3)] bg-[var(--surface-5)] font-medium text-[var(--text-muted)] text-micro transition-[border-color] group-hover:border-[var(--surface-4)] dark:border-[var(--surface-4)] dark:group-hover:border-[var(--surface-5)]'>
|
||||
+{hiddenConnectorCount}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{hiddenConnectorLabels.join(', ')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -71,22 +71,35 @@ function connectorCell(connectorTypes?: string[]): ResourceCell {
|
||||
|
||||
if (entries.length === 0) return { label: EMPTY_CELL_PLACEHOLDER }
|
||||
|
||||
const visibleEntries = entries.slice(0, 3)
|
||||
const hiddenEntries = entries.slice(3)
|
||||
|
||||
return {
|
||||
content: (
|
||||
<div className='flex items-center gap-1'>
|
||||
{entries.map(({ type, def }) => {
|
||||
{visibleEntries.map(({ type, def }) => {
|
||||
const Icon = def.icon
|
||||
return (
|
||||
<Tooltip.Root key={type}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='flex-shrink-0'>
|
||||
<Icon className='size-3.5' />
|
||||
<span className='flex size-5 flex-shrink-0 items-center justify-center rounded-md bg-[var(--surface-4)] text-[var(--text-secondary)]'>
|
||||
<Icon className='size-[13px]' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{def.name}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
})}
|
||||
{hiddenEntries.length > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='flex size-5 flex-shrink-0 items-center justify-center rounded-md bg-[var(--surface-4)] font-medium text-[var(--text-muted)] text-micro'>
|
||||
+{hiddenEntries.length}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{hiddenEntries.map(({ def }) => def.name).join(', ')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user