fix(settings): chip-consistency + shared credential-style resource row (#5308)

- chip: move default/filled hover into active-keyed compound variants so raw chipVariants({...}) renders identically to cn(chipVariants({...})); fixes sidebar/settings-sidebar active-hover divergence (no change to cn-wrapped consumers)
- integrations: 'Explore in chat' uses active chip (darkens on hover) instead of floating text
- data-retention: fold the retention-policies helper into the page description; drop the redundant wrapper
- settings: extract shared SettingsResourceRow (rounded icon tile + title/desc + trailing, icons normalized to 20px); migrate recently-deleted, byok, and credential-sets onto it; recently-deleted actions are now Chips
This commit is contained in:
Waleed
2026-06-30 19:17:14 -07:00
committed by GitHub
parent 5055b31e82
commit 0990a17f82
9 changed files with 179 additions and 160 deletions
@@ -30,6 +30,7 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
<div className='relative'>
<IntegrationsShowcase />
<Chip
active
rightIcon={ArrowRight}
onClick={() => {
storeCuratedPrompt(prompt)
@@ -23,6 +23,7 @@ import {
import { BYOKProviderKeysModal } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal'
import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
const logger = createLogger('BYOKKeyManager')
@@ -278,21 +279,13 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
const Icon = provider.icon
return (
<div key={provider.id} className='flex items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
<Icon className='size-5' />
</div>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{provider.name}</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{provider.description}
</span>
</div>
</div>
{renderActions(provider)}
</div>
<SettingsResourceRow
key={provider.id}
icon={<Icon />}
title={provider.name}
description={provider.description}
trailing={renderActions(provider)}
/>
)
}
@@ -34,6 +34,7 @@ import { getUserRole } from '@/lib/workspaces/organization'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
type CredentialSet,
@@ -632,33 +633,23 @@ export function CredentialSets() {
<div className='flex flex-col gap-4.5'>
{filteredInvitations.length > 0 && (
<SettingsSection label='Pending Invitations'>
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-2'>
{filteredInvitations.map((invitation) => (
<div
<SettingsResourceRow
key={invitation.invitationId}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(invitation.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{invitation.credentialSetName}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{invitation.organizationName}
</span>
</div>
</div>
<Chip
variant='primary'
onClick={() => handleAcceptInvitation(invitation.token)}
disabled={acceptInvitation.isPending}
>
{acceptInvitation.isPending ? 'Accepting...' : 'Accept'}
</Chip>
</div>
icon={getProviderIcon(invitation.providerId)}
title={invitation.credentialSetName}
description={invitation.organizationName}
trailing={
<Chip
variant='primary'
onClick={() => handleAcceptInvitation(invitation.token)}
disabled={acceptInvitation.isPending}
>
{acceptInvitation.isPending ? 'Accepting...' : 'Accept'}
</Chip>
}
/>
))}
</div>
</SettingsSection>
@@ -666,34 +657,24 @@ export function CredentialSets() {
{filteredMemberships.length > 0 && (
<SettingsSection label='My Memberships'>
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-2'>
{filteredMemberships.map((membership) => (
<div
<SettingsResourceRow
key={membership.membershipId}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(membership.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{membership.credentialSetName}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{membership.organizationName}
</span>
</div>
</div>
<Chip
onClick={() =>
handleLeave(membership.credentialSetId, membership.credentialSetName)
}
disabled={leaveCredentialSet.isPending}
>
Leave
</Chip>
</div>
icon={getProviderIcon(membership.providerId)}
title={membership.credentialSetName}
description={membership.organizationName}
trailing={
<Chip
onClick={() =>
handleLeave(membership.credentialSetId, membership.credentialSetName)
}
disabled={leaveCredentialSet.isPending}
>
Leave
</Chip>
}
/>
))}
</div>
</SettingsSection>
@@ -709,26 +690,14 @@ export function CredentialSets() {
No polling groups created yet
</div>
) : (
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-2'>
{filteredOwnedSets.map((set) => (
<div
<SettingsResourceRow
key={set.id}
className='flex items-center justify-between rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex items-center gap-2.5'>
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
{getProviderIcon(set.providerId)}
</div>
<div className='flex flex-col'>
<span className='text-[14px] text-[var(--text-body)]'>
{set.name}
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className='flex items-center gap-1'>
icon={getProviderIcon(set.providerId)}
title={set.name}
description={`${set.memberCount} member${set.memberCount !== 1 ? 's' : ''}`}
trailing={
<RowActionsMenu
label='Group actions'
actions={[
@@ -741,8 +710,8 @@ export function CredentialSets() {
},
]}
/>
</div>
</div>
}
/>
))}
</div>
)}
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Button, ChipInput, ChipModalTabs } from '@sim/emcn'
import { Chip, ChipInput, ChipModalTabs } from '@sim/emcn'
import { Folder, Search, Workflow } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
@@ -21,6 +21,7 @@ import {
} from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
@@ -82,7 +83,7 @@ const SORT_OPTIONS: ColumnOption[] = [
{ id: 'type', label: 'Type' },
]
const ICON_CLASS = 'size-[14px]'
const ICON_CLASS = 'size-5'
const RESOURCE_TYPE_TO_MOTHERSHIP: Partial<
Record<Exclude<ResourceType, 'all'>, MothershipResourceType>
@@ -464,45 +465,40 @@ export function RecentlyDeleted() {
const isRestored = restoredItems.has(resource.id)
return (
<div
<SettingsResourceRow
key={resource.id}
className='flex items-center gap-2.5 rounded-lg p-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<ResourceIcon resource={resource} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{resource.name}
</span>
<span className='text-[var(--text-muted)] text-small'>
icon={<ResourceIcon resource={resource} />}
title={resource.name}
description={
<>
{TYPE_LABEL[resource.type]}
{' \u00b7 '}
Deleted {formatDate(resource.deletedAt)}
</span>
</div>
{isRestoring ? (
<Button variant='primary' size='sm' disabled className='shrink-0'>
Restoring...
</Button>
) : isRestored ? (
<div className='flex shrink-0 items-center gap-2'>
<span className='text-[var(--text-muted)] text-small'>Restored</span>
<Button variant='primary' size='sm' onClick={() => handleView(resource)}>
View
</Button>
</div>
) : (
<Button
variant='primary'
size='sm'
onClick={() => void handleRestore(resource)}
className='shrink-0'
>
Restore
</Button>
)}
</div>
</>
}
trailing={
isRestoring ? (
<Chip variant='primary' disabled className='shrink-0'>
Restoring...
</Chip>
) : isRestored ? (
<div className='flex shrink-0 items-center gap-2'>
<span className='text-[var(--text-muted)] text-small'>Restored</span>
<Chip variant='primary' onClick={() => handleView(resource)}>
View
</Chip>
</div>
) : (
<Chip
variant='primary'
onClick={() => void handleRestore(resource)}
className='shrink-0'
>
Restore
</Chip>
)
}
/>
)
})}
</div>
@@ -0,0 +1 @@
export { SettingsResourceRow } from './settings-resource-row'
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
/**
* The canonical settings "resource row": a rounded-bordered icon tile, a
* title + muted description text block, and an optional trailing slot
* (action chips, a {@link RowActionsMenu}, a status label, etc.).
*
* Single source of truth for the credential-style row shared by the BYOK key
* manager, credential sets, and recently-deleted lists — never re-derive the
* tile/text chrome per consumer. The tile force-sizes any `<svg>`/`<img>` it
* contains to 20px, so callers pass their raw icon node without pre-sizing it.
*/
interface SettingsResourceRowProps {
/** Icon node centered in the tile; any `<svg>`/`<img>` is normalized to 20px. */
icon: ReactNode
/** Primary line — truncates. */
title: ReactNode
/** Secondary muted line — truncates. */
description?: ReactNode
/** Trailing element pinned to the row's end (chips, actions menu, status). */
trailing?: ReactNode
}
const TILE_CLASS =
'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] [&_img]:size-5 [&_svg]:size-5'
export function SettingsResourceRow({
icon,
title,
description,
trailing,
}: SettingsResourceRowProps) {
return (
<div className='flex items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className={TILE_CLASS}>{icon}</div>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
{description != null && (
<span className='truncate text-[12px] text-[var(--text-muted)]'>{description}</span>
)}
</div>
</div>
{trailing}
</div>
)
}
@@ -248,7 +248,8 @@ export const allNavigationItems: NavigationItem[] = [
{
id: 'data-retention',
label: 'Data retention',
description: 'Control data retention windows and PII redaction.',
description:
'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.',
icon: Database,
section: 'enterprise',
requiresHosted: true,
@@ -787,50 +787,45 @@ export function DataRetentionSettings() {
]}
>
<SettingsSection label='Retention policies'>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>
Workspaces without an override inherit the organization defaults.
</span>
<div className='-mx-2 flex flex-col gap-y-0.5'>
<div className='-mx-2 flex flex-col gap-y-0.5'>
<button
type='button'
onClick={openEditOrg}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex items-center gap-2'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
Organization
</span>
<ChipTag variant='gray' className='flex-shrink-0'>
Default
</ChipTag>
</div>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{orgRowSummary()}
</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</button>
{overrideWorkspaceIds.map((workspaceId) => (
<button
key={workspaceId}
type='button'
onClick={openEditOrg}
onClick={() => openEditOverride(workspaceId)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex items-center gap-2'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
Organization
</span>
<ChipTag variant='gray' className='flex-shrink-0'>
Default
</ChipTag>
</div>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{workspaceName(workspaceId)}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{orgRowSummary()}
{overrideRowSummary(workspaceId)}
</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</button>
{overrideWorkspaceIds.map((workspaceId) => (
<button
key={workspaceId}
type='button'
onClick={() => openEditOverride(workspaceId)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{workspaceName(workspaceId)}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{overrideRowSummary(workspaceId)}
</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</button>
))}
</div>
))}
</div>
</SettingsSection>
</SettingsPanel>
+18 -2
View File
@@ -38,14 +38,20 @@ import {
* `active` renders the default/filled chip in its selected state — `--surface-active` at rest, one surface darker
* (`--surface-6`) on hover. `fullWidth` swaps `inline-flex` for block-level `flex`. `flush` removes the default
* `mx-0.5` cluster margin — use when a single chip sits in its own layout slot (grid/table cell).
*
* The default/filled hover lives in `active`-keyed compound variants (not the base variant string) so the
* rest/hover classes are mutually exclusive — a chip renders exactly ONE `hover-hover:bg-*`. This keeps raw
* `chipVariants({...})` consumers identical to `cn(chipVariants({...}))` ones; folding the non-active hover back
* into the variant string would emit two conflicting hover classes that only `cn`'s tailwind-merge resolves,
* silently diverging raw consumers (e.g. an active row that darkens with `Chip` but not with raw `chipVariants`).
*/
const chipVariants = cva(
`group cursor-pointer ${chipGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
{
variants: {
variant: {
default: 'hover-hover:bg-[var(--surface-active)]',
filled: `${chipFilledFillTokens} hover-hover:bg-[var(--surface-active)]`,
default: '',
filled: chipFilledFillTokens,
primary: `${chipPrimaryFillTokens} hover-hover:bg-[var(--text-body)] hover-hover:text-[var(--text-inverse)] dark:hover-hover:bg-[var(--text-secondary)] dark:hover-hover:text-[var(--bg)]`,
destructive:
'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106',
@@ -59,11 +65,21 @@ const chipVariants = cva(
flush: { true: 'mx-0', false: 'mx-0.5' },
},
compoundVariants: [
{
variant: 'default',
active: false,
className: 'hover-hover:bg-[var(--surface-active)]',
},
{
variant: 'default',
active: true,
className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]',
},
{
variant: 'filled',
active: false,
className: 'hover-hover:bg-[var(--surface-active)]',
},
{
variant: 'filled',
active: true,