fix(resource): prevent permission-gated breadcrumb items from flashing on load (#4732)

* improvement(kb-connectors): align connector UI surfaces and strip redundant description suffix

- Fix icon colors to use --text-icon token across connector cards and modal
- Switch Reconnect/Update access buttons from active to primary variant
- Lift search box surface from --surface-2 to --surface-3 so it's visible against modal bg
- Replace raw <button> elements with emcn Button in base.tsx
- Shrink Connected Sources modal from lg to md
- Strip " to/into/from your knowledge base" from all 27 connector descriptions to prevent overflow

* fix(resource): replace raw pagination button with emcn Button

* fix(resource): prevent permission-gated breadcrumb items from flashing on load

* fix(resource): self-remove keydown listener on Escape and include session loading in isLoading guard
This commit is contained in:
Waleed
2026-05-23 12:40:37 -07:00
committed by GitHub
parent a14d37429d
commit 1286519498
34 changed files with 111 additions and 58 deletions
@@ -219,6 +219,36 @@ export const ResourceTable = memo(function ResourceTable({
direction: 'desc',
})
const [contextMenuRowId, setContextMenuRowId] = useState<string | null>(null)
const wrappedOnRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
setContextMenuRowId(rowId)
onRowContextMenu?.(e, rowId)
},
[onRowContextMenu]
)
useEffect(() => {
if (!contextMenuRowId) return
const clear = () => setContextMenuRowId(null)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', handleKeyDown)
clear()
}
}
const timeoutId = setTimeout(() => {
document.addEventListener('pointerdown', clear, { once: true })
document.addEventListener('keydown', handleKeyDown)
}, 0)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('pointerdown', clear)
document.removeEventListener('keydown', handleKeyDown)
}
}, [contextMenuRowId])
const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => {
setInternalSort({ column, direction })
}, [])
@@ -343,7 +373,8 @@ export const ResourceTable = memo(function ResourceTable({
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
))}
@@ -403,17 +434,18 @@ const Pagination = memo(function Pagination({
}
if (page < 1 || page > totalPages) return null
return (
<button
<Button
key={page}
type='button'
variant='ghost'
onClick={() => onPageChange(page)}
className={cn(
'font-medium text-sm transition-colors hover-hover:text-[var(--text-body)]',
'h-auto p-0 font-medium text-sm transition-colors hover-hover:bg-transparent hover-hover:text-[var(--text-body)]',
page === currentPage ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{page}
</button>
</Button>
)
})}
</div>
@@ -460,6 +492,7 @@ interface DataRowProps {
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
isContextMenuTarget?: boolean
hasCheckbox: boolean
}
@@ -472,6 +505,7 @@ const DataRow = memo(function DataRow({
onRowClick,
onRowHover,
onRowContextMenu,
isContextMenuTarget,
hasCheckbox,
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
@@ -554,7 +588,7 @@ const DataRow = memo(function DataRow({
onRowClick && 'cursor-pointer',
isDraggable && 'cursor-grab active:cursor-grabbing',
isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]',
(selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]',
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
)}
@@ -1585,10 +1585,11 @@ export function Files() {
}
: undefined,
dropdownItems:
isCurrentFolder && canEdit
isCurrentFolder && (canEdit || userPermissions.isLoading)
? [
{
label: 'Rename',
disabled: !canEdit,
onClick: () => breadcrumbRenameRef.current.startRename(folder.id, folder.name),
},
]
@@ -1605,6 +1606,7 @@ export function Files() {
router,
workspaceId,
canEdit,
userPermissions.isLoading,
breadcrumbRename.editingId,
breadcrumbRename.editValue,
])
@@ -814,15 +814,26 @@ export function KnowledgeBase({
}
: undefined,
dropdownItems: [
...(userPermissions.canEdit
...(userPermissions.canEdit || userPermissions.isLoading
? [
{
label: 'Rename',
icon: Pencil,
disabled: !userPermissions.canEdit,
onClick: () => kbRename.startRename(id, knowledgeBaseName),
},
{ label: 'Tags', icon: Tag, onClick: () => setShowTagsModal(true) },
{ label: 'Delete', icon: Trash, onClick: () => setShowDeleteDialog(true) },
{
label: 'Tags',
icon: Tag,
disabled: !userPermissions.canEdit,
onClick: () => setShowTagsModal(true),
},
{
label: 'Delete',
icon: Trash,
disabled: !userPermissions.canEdit,
onClick: () => setShowDeleteDialog(true),
},
]
: []),
],
@@ -830,8 +841,15 @@ export function KnowledgeBase({
]
const headerActions: HeaderAction[] = [
...(userPermissions.canEdit
? [{ label: 'New connector', icon: Plus, onClick: () => setShowAddConnectorModal(true) }]
...(userPermissions.canEdit || userPermissions.isLoading
? [
{
label: 'New connector',
icon: Plus,
disabled: !userPermissions.canEdit,
onClick: () => setShowAddConnectorModal(true),
},
]
: []),
]
@@ -886,18 +904,18 @@ export function KnowledgeBase({
/>
</div>
{enabledFilter.length > 0 && (
<button
type='button'
<Button
variant='ghost'
onClick={() => {
setEnabledFilter([])
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
className='h-[32px] w-full text-[var(--text-secondary)] text-caption'
>
Clear status filter
</button>
</Button>
)}
<TagFilterSection
tagDefinitions={tagDefinitions}
@@ -1322,7 +1340,7 @@ export function KnowledgeBase({
)}
<Modal open={showConnectorsModal} onOpenChange={setShowConnectorsModal}>
<ModalContent size='lg'>
<ModalContent size='md'>
<ModalHeader>Connected Sources</ModalHeader>
<ModalDescription className='sr-only'>
Manage connected data sources for this knowledge base
@@ -1535,13 +1553,13 @@ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectio
>
<div className='flex items-center justify-between'>
<Label className='text-[var(--text-muted)] text-xs'>Tag</Label>
<button
type='button'
<Button
variant='ghost'
className='size-5 p-0 text-[var(--text-muted)] hover-hover:text-[var(--text-error)]'
onClick={() => removeFilter(entry.id)}
className='text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-error)]'
>
<X className='size-3' />
</button>
</Button>
</div>
<Combobox
options={tagOptions}
@@ -246,7 +246,7 @@ export function AddConnectorModal({
<ModalBody className={step === 'select-type' ? 'pt-2 pb-3' : 'pb-3'}>
{step === 'select-type' ? (
<div className='flex min-h-0 flex-col gap-2.5'>
<div className='flex h-8 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 transition-colors duration-100 hover-hover:border-[var(--border-1)] hover-hover:bg-[var(--surface-3)]'>
<div className='flex h-8 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-3)] px-2 transition-colors duration-100 hover-hover:border-[var(--border-1)] hover-hover:bg-[var(--surface-4)]'>
<Search
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
strokeWidth={2}
@@ -549,7 +549,7 @@ function ConnectorTypeCard({ config, onClick }: ConnectorTypeCardProps) {
className='group flex min-h-10 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
onClick={onClick}
>
<Icon className='size-[18px] flex-shrink-0' />
<Icon className='size-[18px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='flex min-w-0 flex-1 flex-col gap-[1px]'>
<span className='truncate font-medium text-[var(--text-body)] text-small'>
{config.name}
@@ -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 font-medium 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 font-medium text-[var(--text-muted)] text-small'>
<Loader className='size-3.5' animate />
Loading…
</div>
@@ -372,7 +372,7 @@ function ConnectorCard({
<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)]' />}
{Icon && <Icon className='size-5 text-[var(--text-icon)]' />}
{connector.status === 'disabled' && (
<AlertTriangle className='-right-0.5 -top-0.5 absolute size-3 text-[var(--caution)]' />
)}
@@ -532,7 +532,7 @@ function ConnectorCard({
</p>
{canEdit && serviceId && providerId && (
<Button
variant='active'
variant='primary'
onClick={() => {
if (connector.credentialId) {
writeOAuthReturnContext({
@@ -566,7 +566,7 @@ function ConnectorCard({
</div>
{canEdit && (
<Button
variant='active'
variant='primary'
onClick={() => {
if (connector.credentialId) {
writeOAuthReturnContext({
+1 -1
View File
@@ -75,7 +75,7 @@ function parseCursor(cursor?: string): string | undefined {
export const airtableConnector: ConnectorConfig = {
id: 'airtable',
name: 'Airtable',
description: 'Sync records from an Airtable table into your knowledge base',
description: 'Sync records from an Airtable table',
version: '1.0.0',
icon: AirtableIcon,
+1 -1
View File
@@ -133,7 +133,7 @@ async function listWorkspaceProjects(
export const asanaConnector: ConnectorConfig = {
id: 'asana',
name: 'Asana',
description: 'Sync tasks from Asana into your knowledge base',
description: 'Sync tasks from Asana',
version: '1.0.0',
icon: AsanaIcon,
+1 -1
View File
@@ -146,7 +146,7 @@ function cqlResultToStub(item: Record<string, unknown>, domain: string): Externa
export const confluenceConnector: ConnectorConfig = {
id: 'confluence',
name: 'Confluence',
description: 'Sync pages from a Confluence space into your knowledge base',
description: 'Sync pages from a Confluence space',
version: '1.1.0',
icon: ConfluenceIcon,
+1 -1
View File
@@ -137,7 +137,7 @@ function formatMessages(messages: DiscordMessage[]): string {
export const discordConnector: ConnectorConfig = {
id: 'discord',
name: 'Discord',
description: 'Sync channel messages from Discord into your knowledge base',
description: 'Sync channel messages from Discord',
version: '1.0.0',
icon: DiscordIcon,
+1 -1
View File
@@ -98,7 +98,7 @@ function fileToStub(entry: DropboxFileEntry): ExternalDocument {
export const dropboxConnector: ConnectorConfig = {
id: 'dropbox',
name: 'Dropbox',
description: 'Sync text files from Dropbox into your knowledge base',
description: 'Sync text files from Dropbox',
version: '1.0.0',
icon: DropboxIcon,
+1 -1
View File
@@ -366,7 +366,7 @@ async function apiGetNote(
export const evernoteConnector: ConnectorConfig = {
id: 'evernote',
name: 'Evernote',
description: 'Sync notes from Evernote into your knowledge base',
description: 'Sync notes from Evernote',
version: '1.0.0',
icon: EvernoteIcon,
+1 -1
View File
@@ -123,7 +123,7 @@ function formatTranscriptContent(transcript: FirefliesTranscript): string {
export const firefliesConnector: ConnectorConfig = {
id: 'fireflies',
name: 'Fireflies',
description: 'Sync meeting transcripts from Fireflies.ai into your knowledge base',
description: 'Sync meeting transcripts from Fireflies.ai',
version: '1.0.0',
icon: FirefliesIcon,
+1 -1
View File
@@ -179,7 +179,7 @@ function treeItemToStub(
export const githubConnector: ConnectorConfig = {
id: 'github',
name: 'GitHub',
description: 'Sync files from a GitHub repository into your knowledge base',
description: 'Sync files from a GitHub repository',
version: '1.0.0',
icon: GithubIcon,
+1 -1
View File
@@ -339,7 +339,7 @@ function threadToStub(thread: {
export const gmailConnector: ConnectorConfig = {
id: 'gmail',
name: 'Gmail',
description: 'Sync email threads from Gmail into your knowledge base',
description: 'Sync email threads from Gmail',
version: '1.0.0',
icon: GmailIcon,
@@ -246,7 +246,7 @@ function eventToDocument(
export const googleCalendarConnector: ConnectorConfig = {
id: 'google_calendar',
name: 'Google Calendar',
description: 'Sync calendar events from Google Calendar into your knowledge base',
description: 'Sync calendar events from Google Calendar',
version: '1.0.0',
icon: GoogleCalendarIcon,
@@ -163,7 +163,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {
export const googleDocsConnector: ConnectorConfig = {
id: 'google_docs',
name: 'Google Docs',
description: 'Sync Google Docs documents into your knowledge base',
description: 'Sync Google Docs documents',
version: '1.0.0',
icon: GoogleDocsIcon,
@@ -171,7 +171,7 @@ function fileToStub(file: DriveFile): ExternalDocument {
export const googleDriveConnector: ConnectorConfig = {
id: 'google_drive',
name: 'Google Drive',
description: 'Sync documents from Google Drive into your knowledge base',
description: 'Sync documents from Google Drive',
version: '1.0.0',
icon: GoogleDriveIcon,
@@ -199,7 +199,7 @@ async function sheetToDocument(
export const googleSheetsConnector: ConnectorConfig = {
id: 'google_sheets',
name: 'Google Sheets',
description: 'Sync spreadsheet data from Google Sheets into your knowledge base',
description: 'Sync spreadsheet data from Google Sheets',
version: '1.0.0',
icon: GoogleSheetsIcon,
+1 -1
View File
@@ -174,7 +174,7 @@ function recordToDocument(
export const hubspotConnector: ConnectorConfig = {
id: 'hubspot',
name: 'HubSpot',
description: 'Sync CRM records from HubSpot into your knowledge base',
description: 'Sync CRM records from HubSpot',
version: '1.0.0',
icon: HubspotIcon,
+1 -1
View File
@@ -269,7 +269,7 @@ function formatArticle(article: IntercomArticle): string {
export const intercomConnector: ConnectorConfig = {
id: 'intercom',
name: 'Intercom',
description: 'Sync Help Center articles and conversations from Intercom into your knowledge base',
description: 'Sync Help Center articles and conversations from Intercom',
version: '1.0.0',
icon: IntercomIcon,
+1 -1
View File
@@ -106,7 +106,7 @@ function issueToFullDocument(issue: Record<string, unknown>, domain: string): Ex
export const jiraConnector: ConnectorConfig = {
id: 'jira',
name: 'Jira',
description: 'Sync issues from a Jira project into your knowledge base',
description: 'Sync issues from a Jira project',
version: '1.0.0',
icon: JiraIcon,
+1 -1
View File
@@ -203,7 +203,7 @@ function buildIssuesQuery(
export const linearConnector: ConnectorConfig = {
id: 'linear',
name: 'Linear',
description: 'Sync issues from Linear into your knowledge base',
description: 'Sync issues from Linear',
version: '1.0.0',
icon: LinearIcon,
@@ -193,7 +193,7 @@ async function resolveChannel(
export const microsoftTeamsConnector: ConnectorConfig = {
id: 'microsoft_teams',
name: 'Microsoft Teams',
description: 'Sync channel messages from Microsoft Teams into your knowledge base',
description: 'Sync channel messages from Microsoft Teams',
version: '1.0.0',
icon: MicrosoftTeamsIcon,
+1 -1
View File
@@ -179,7 +179,7 @@ function pageToStub(page: Record<string, unknown>): ExternalDocument {
export const notionConnector: ConnectorConfig = {
id: 'notion',
name: 'Notion',
description: 'Sync pages from a Notion workspace into your knowledge base',
description: 'Sync pages from a Notion workspace',
version: '1.0.0',
icon: NotionIcon,
+1 -1
View File
@@ -136,7 +136,7 @@ function buildListUrl(folderPath?: string): string {
export const onedriveConnector: ConnectorConfig = {
id: 'onedrive',
name: 'OneDrive',
description: 'Sync documents from Microsoft OneDrive into your knowledge base',
description: 'Sync documents from Microsoft OneDrive',
version: '1.0.0',
icon: MicrosoftOneDriveIcon,
+1 -1
View File
@@ -269,7 +269,7 @@ function formatConversation(
export const outlookConnector: ConnectorConfig = {
id: 'outlook',
name: 'Outlook',
description: 'Sync email conversations from Outlook into your knowledge base',
description: 'Sync email conversations from Outlook',
version: '1.0.0',
icon: OutlookIcon,
+1 -1
View File
@@ -245,7 +245,7 @@ function resolveSortConfig(sourceConfig: Record<string, unknown>): {
export const redditConnector: ConnectorConfig = {
id: 'reddit',
name: 'Reddit',
description: 'Sync subreddit posts and comments from Reddit into your knowledge base',
description: 'Sync subreddit posts and comments from Reddit',
version: '1.0.0',
icon: RedditIcon,
+1 -1
View File
@@ -268,7 +268,7 @@ function recordToDocument(
export const salesforceConnector: ConnectorConfig = {
id: 'salesforce',
name: 'Salesforce',
description: 'Sync records from Salesforce into your knowledge base',
description: 'Sync records from Salesforce',
version: '1.0.0',
icon: SalesforceIcon,
+1 -1
View File
@@ -279,7 +279,7 @@ function decodeCursor(cursor: string): PaginationState {
export const sharepointConnector: ConnectorConfig = {
id: 'sharepoint',
name: 'SharePoint',
description: 'Sync documents from a SharePoint site into your knowledge base',
description: 'Sync documents from a SharePoint site',
version: '1.0.0',
icon: MicrosoftSharepointIcon,
+1 -1
View File
@@ -486,7 +486,7 @@ async function buildSlackChannelDocument(
export const slackConnector: ConnectorConfig = {
id: 'slack',
name: 'Slack',
description: 'Sync channel messages from Slack into your knowledge base',
description: 'Sync channel messages from Slack',
version: '1.0.0',
icon: SlackIcon,
+1 -1
View File
@@ -83,7 +83,7 @@ export const webflowConnector: ConnectorConfig = {
id: 'webflow',
name: 'Webflow',
description:
'Sync CMS collection items from a Webflow site into your knowledge base. Note: Webflow OAuth tokens do not support refresh — you may need to reconnect periodically.',
'Sync CMS collection items from a Webflow site. Note: Webflow OAuth tokens do not support refresh — you may need to reconnect periodically.',
version: '1.0.0',
icon: WebflowIcon,
+1 -2
View File
@@ -314,8 +314,7 @@ function ticketToDocument(
export const zendeskConnector: ConnectorConfig = {
id: 'zendesk',
name: 'Zendesk',
description:
'Sync Help Center articles and support tickets from Zendesk into your knowledge base',
description: 'Sync Help Center articles and support tickets from Zendesk',
version: '1.0.0',
icon: ZendeskIcon,
+4 -4
View File
@@ -33,17 +33,17 @@ export function useUserPermissions(
permissionsLoading = false,
permissionsError: string | null = null
): WorkspaceUserPermissions {
const { data: session } = useSession()
const { data: session, isPending: sessionLoading } = useSession()
const userPermissions = useMemo((): WorkspaceUserPermissions => {
const sessionEmail = session?.user?.email
if (permissionsLoading || !sessionEmail) {
if (permissionsLoading || sessionLoading || !sessionEmail) {
return {
canRead: false,
canEdit: false,
canAdmin: false,
userPermissions: 'read',
isLoading: permissionsLoading,
isLoading: permissionsLoading || sessionLoading,
error: permissionsError,
}
}
@@ -99,7 +99,7 @@ export function useUserPermissions(
isLoading: false,
error: permissionsError,
}
}, [session, workspacePermissions, permissionsLoading, permissionsError])
}, [session, sessionLoading, workspacePermissions, permissionsLoading, permissionsError])
return userPermissions
}