mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+1
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+40
-71
@@ -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>
|
||||
)}
|
||||
|
||||
+33
-37
@@ -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>
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SettingsResourceRow } from './settings-resource-row'
|
||||
+47
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user