feat(emcn): tag input, tooltip shortcut

This commit is contained in:
Emir Karabeg
2026-01-07 20:37:55 -08:00
parent b403378043
commit 51f9380cc7
25 changed files with 603 additions and 532 deletions

View File

@@ -28,7 +28,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute='class'
defaultTheme='system'
defaultTheme='dark'
enableSystem
disableTransitionOnChange
storageKey='sim-theme'

View File

@@ -18,10 +18,11 @@ import {
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
TagInput,
type TagItem,
} from '@/components/emcn'
import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
@@ -156,8 +157,7 @@ export function NotificationSettings({
errorCountThreshold: 10,
})
const [emailInputValue, setEmailInputValue] = useState('')
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
@@ -225,8 +225,7 @@ export function NotificationSettings({
})
setFormErrors({})
setEditingId(null)
setEmailInputValue('')
setInvalidEmails([])
setEmailItems([])
}, [])
const handleClose = useCallback(() => {
@@ -243,81 +242,37 @@ export function NotificationSettings({
const normalized = email.trim().toLowerCase()
const validation = quickValidateEmail(normalized)
if (formData.emailRecipients.includes(normalized) || invalidEmails.includes(normalized)) {
if (emailItems.some((item) => item.value === normalized)) {
return false
}
if (!validation.isValid) {
setInvalidEmails((prev) => [...prev, normalized])
setEmailInputValue('')
return false
setEmailItems((prev) => [...prev, { value: normalized, isValid: validation.isValid }])
if (validation.isValid) {
setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
setFormData((prev) => ({
...prev,
emailRecipients: [...prev.emailRecipients, normalized],
}))
}
setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
setFormData((prev) => ({
...prev,
emailRecipients: [...prev.emailRecipients, normalized],
}))
setEmailInputValue('')
return true
return validation.isValid
},
[formData.emailRecipients, invalidEmails]
[emailItems]
)
const handleRemoveEmail = useCallback((emailToRemove: string) => {
setFormData((prev) => ({
...prev,
emailRecipients: prev.emailRecipients.filter((e) => e !== emailToRemove),
}))
}, [])
const handleRemoveInvalidEmail = useCallback((index: number) => {
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}, [])
const handleEmailKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && emailInputValue.trim()) {
e.preventDefault()
addEmail(emailInputValue)
}
if (e.key === 'Backspace' && !emailInputValue) {
if (invalidEmails.length > 0) {
handleRemoveInvalidEmail(invalidEmails.length - 1)
} else if (formData.emailRecipients.length > 0) {
handleRemoveEmail(formData.emailRecipients[formData.emailRecipients.length - 1])
}
const handleRemoveEmailItem = useCallback(
(_value: string, index: number, isValid: boolean) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid && itemToRemove) {
setFormData((prev) => ({
...prev,
emailRecipients: prev.emailRecipients.filter((e) => e !== itemToRemove.value),
}))
}
},
[
emailInputValue,
addEmail,
invalidEmails,
formData.emailRecipients,
handleRemoveInvalidEmail,
handleRemoveEmail,
]
)
const handleEmailPaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
let addedCount = 0
pastedEmails.forEach((email) => {
if (addEmail(email)) {
addedCount++
}
})
if (addedCount === 0 && pastedEmails.length === 1) {
setEmailInputValue(emailInputValue + pastedEmails[0])
}
},
[addEmail, emailInputValue]
[emailItems]
)
const validateForm = (): boolean => {
@@ -356,8 +311,11 @@ export function NotificationSettings({
} else if (formData.emailRecipients.length > 10) {
errors.emailRecipients = 'Maximum 10 email recipients allowed'
}
if (invalidEmails.length > 0) {
errors.emailRecipients = `Invalid email addresses: ${invalidEmails.join(', ')}`
const invalidEmailValues = emailItems
.filter((item) => !item.isValid)
.map((item) => item.value)
if (invalidEmailValues.length > 0) {
errors.emailRecipients = `Invalid email addresses: ${invalidEmailValues.join(', ')}`
}
}
@@ -536,8 +494,9 @@ export function NotificationSettings({
inactivityHours: subscription.alertConfig?.inactivityHours || 24,
errorCountThreshold: subscription.alertConfig?.errorCountThreshold || 10,
})
setEmailInputValue('')
setInvalidEmails([])
setEmailItems(
(subscription.emailRecipients || []).map((email) => ({ value: email, isValid: true }))
)
setShowForm(true)
}
@@ -692,37 +651,13 @@ export function NotificationSettings({
{activeTab === 'email' && (
<div className='flex flex-col gap-[8px]'>
<Label className='text-[var(--text-secondary)]'>Email Recipients</Label>
<div className='scrollbar-hide flex max-h-32 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] focus-within:outline-none dark:bg-[var(--surface-5)]'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => handleRemoveInvalidEmail(index)}
isInvalid={true}
/>
))}
{formData.emailRecipients.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => handleRemoveEmail(email)}
/>
))}
<input
type='text'
value={emailInputValue}
onChange={(e) => setEmailInputValue(e.target.value)}
onKeyDown={handleEmailKeyDown}
onPaste={handleEmailPaste}
onBlur={() => emailInputValue.trim() && addEmail(emailInputValue)}
placeholder={
formData.emailRecipients.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className='min-w-[180px] flex-1 border-none bg-transparent p-0 font-medium font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
<TagInput
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={handleRemoveEmailItem}
placeholder='Enter emails'
placeholderWithTags='Add email'
/>
{formErrors.emailRecipients && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
)}
@@ -1351,37 +1286,3 @@ export function NotificationSettings({
</>
)
}
interface EmailTagProps {
email: string
onRemove: () => void
isInvalid?: boolean
}
function EmailTag({ email, onRemove, isInvalid }: EmailTagProps) {
return (
<div
className={cn(
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
isInvalid
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
: 'border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='max-w-[200px] truncate'>{email}</span>
<button
type='button'
onClick={onRemove}
className={cn(
'flex-shrink-0 transition-colors focus:outline-none',
isInvalid
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
)}
aria-label={`Remove ${email}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
</button>
</div>
)
}

View File

@@ -80,7 +80,7 @@ export function BlockContextMenu({
}}
>
<span>Copy</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>C</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>C</span>
</PopoverItem>
<PopoverItem
className='group'
@@ -91,7 +91,7 @@ export function BlockContextMenu({
}}
>
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>V</span>
</PopoverItem>
{!hasStarterBlock && (
<PopoverItem
@@ -176,7 +176,7 @@ export function BlockContextMenu({
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
<span className='ml-auto opacity-70 group-hover:opacity-100'></span>
</PopoverItem>
</PopoverContent>
</Popover>

View File

@@ -61,7 +61,7 @@ export function PaneContextMenu({
}}
>
<span>Undo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>Z</span>
</PopoverItem>
<PopoverItem
className='group'
@@ -72,7 +72,7 @@ export function PaneContextMenu({
}}
>
<span>Redo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>Z</span>
</PopoverItem>
{/* Edit and creation actions */}
@@ -86,7 +86,7 @@ export function PaneContextMenu({
}}
>
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>V</span>
</PopoverItem>
<PopoverItem
className='group'
@@ -97,7 +97,7 @@ export function PaneContextMenu({
}}
>
<span>Add Block</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>K</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>K</span>
</PopoverItem>
<PopoverItem
className='group'
@@ -108,7 +108,7 @@ export function PaneContextMenu({
}}
>
<span>Auto-layout</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
{/* Navigation actions */}
@@ -121,7 +121,7 @@ export function PaneContextMenu({
}}
>
<span>Open Logs</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
<PopoverItem
onClick={() => {

View File

@@ -2,7 +2,7 @@ import { memo, useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Button, Tooltip } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -130,14 +130,21 @@ export const Notifications = memo(function Notifications() {
hasAction ? 'line-clamp-2' : 'line-clamp-4'
}`}
>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-1.5 -m-1.5 float-right ml-[16px]'
>
<X className='h-3 w-3' />
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-1.5 -m-1.5 float-right ml-[16px]'
>
<X className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
{notification.level === 'error' && (
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, Check, Clipboard, Eye, EyeOff, Loader2, RefreshCw, X } from 'lucide-react'
import { AlertTriangle, Check, Clipboard, Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react'
import {
Button,
ButtonGroup,
@@ -14,6 +14,8 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
TagInput,
type TagItem,
Textarea,
Tooltip,
} from '@/components/emcn'
@@ -352,6 +354,7 @@ export function ChatDeploy({
</div>
<AuthSelector
key={existingChat?.id ?? 'new'}
authType={formData.authType}
password={formData.password}
emails={formData.emails}
@@ -583,10 +586,11 @@ function AuthSelector({
error,
}: AuthSelectorProps) {
const [showPassword, setShowPassword] = useState(false)
const [emailInputValue, setEmailInputValue] = useState('')
const [emailError, setEmailError] = useState('')
const [copySuccess, setCopySuccess] = useState(false)
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const [emailItems, setEmailItems] = useState<TagItem[]>(() =>
emails.map((email) => ({ value: email, isValid: true }))
)
const handleGeneratePassword = () => {
const newPassword = generatePassword(24)
@@ -607,59 +611,25 @@ function AuthSelector({
const validation = quickValidateEmail(normalized)
const isValid = validation.isValid || isDomainPattern
if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
if (emailItems.some((item) => item.value === normalized)) {
return false
}
if (!isValid) {
setInvalidEmails((prev) => [...prev, normalized])
setEmailInputValue('')
return false
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
if (isValid) {
setEmailError('')
onEmailsChange([...emails, normalized])
}
setEmailError('')
onEmailsChange([...emails, normalized])
setEmailInputValue('')
return true
return isValid
}
const handleRemoveEmail = (emailToRemove: string) => {
onEmailsChange(emails.filter((e) => e !== emailToRemove))
}
const handleRemoveInvalidEmail = (index: number) => {
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && emailInputValue.trim()) {
e.preventDefault()
addEmail(emailInputValue)
}
if (e.key === 'Backspace' && !emailInputValue) {
if (invalidEmails.length > 0) {
handleRemoveInvalidEmail(invalidEmails.length - 1)
} else if (emails.length > 0) {
handleRemoveEmail(emails[emails.length - 1])
}
}
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
let addedCount = 0
pastedEmails.forEach((email) => {
if (addEmail(email)) {
addedCount++
}
})
if (addedCount === 0 && pastedEmails.length === 1) {
setEmailInputValue(emailInputValue + pastedEmails[0])
const handleRemoveEmailItem = (_value: string, index: number, isValid: boolean) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid && itemToRemove) {
onEmailsChange(emails.filter((e) => e !== itemToRemove.value))
}
}
@@ -774,40 +744,14 @@ function AuthSelector({
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
{authType === 'email' ? 'Allowed emails' : 'Allowed SSO emails'}
</Label>
<div className='scrollbar-hide flex max-h-32 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] focus-within:outline-none dark:bg-[var(--surface-5)]'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => handleRemoveInvalidEmail(index)}
disabled={disabled}
isInvalid={true}
/>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => handleRemoveEmail(email)}
disabled={disabled}
/>
))}
<input
type='text'
value={emailInputValue}
onChange={(e) => setEmailInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => emailInputValue.trim() && addEmail(emailInputValue)}
placeholder={
emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails or domains (@example.com)'
}
className='min-w-[180px] flex-1 border-none bg-transparent p-0 font-medium font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50'
disabled={disabled}
/>
</div>
<TagInput
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={handleRemoveEmailItem}
placeholder='Enter emails or domains (@example.com)'
placeholderWithTags='Add email'
disabled={disabled}
/>
{emailError && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{emailError}</p>
)}
@@ -823,40 +767,3 @@ function AuthSelector({
</div>
)
}
interface EmailTagProps {
email: string
onRemove: () => void
disabled?: boolean
isInvalid?: boolean
}
function EmailTag({ email, onRemove, disabled, isInvalid }: EmailTagProps) {
return (
<div
className={cn(
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
isInvalid
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
: 'border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='max-w-[200px] truncate'>{email}</span>
{!disabled && (
<button
type='button'
onClick={onRemove}
className={cn(
'flex-shrink-0 transition-colors focus:outline-none',
isInvalid
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
)}
aria-label={`Remove ${email}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
</button>
)}
</div>
)
}

View File

@@ -2,8 +2,17 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronRight, Eye, EyeOff, Loader2, X } from 'lucide-react'
import { Badge, ButtonGroup, ButtonGroupItem, Input, Label, Textarea } from '@/components/emcn'
import { ChevronDown, ChevronRight, Eye, EyeOff, Loader2 } from 'lucide-react'
import {
Badge,
ButtonGroup,
ButtonGroupItem,
Input,
Label,
TagInput,
type TagItem,
Textarea,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { getEnv } from '@/lib/core/config/env'
import { isDev } from '@/lib/core/config/feature-flags'
@@ -85,7 +94,7 @@ export function FormDeploy({
)
const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public')
const [password, setPassword] = useState('')
const [allowedEmails, setAllowedEmails] = useState<string[]>([])
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [existingForm, setExistingForm] = useState<ExistingForm | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [formUrl, setFormUrl] = useState('')
@@ -143,7 +152,9 @@ export function FormDeploy({
'Your response has been submitted successfully.'
)
setAuthType(form.authType)
setAllowedEmails(form.allowedEmails || [])
setEmailItems(
(form.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (form.customizations?.fieldConfigs) {
setFieldConfigs(form.customizations.fieldConfigs)
}
@@ -210,6 +221,8 @@ export function FormDeploy({
}
}, [workflowId, fieldConfigs.length])
const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
// Validate form
useEffect(() => {
const isValid =
@@ -225,7 +238,7 @@ export function FormDeploy({
title,
authType,
password,
allowedEmails,
allowedEmails.length,
existingForm?.hasPassword,
onValidationChange,
inputFields.length,
@@ -612,41 +625,27 @@ export function FormDeploy({
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Allowed emails
</Label>
<div className='flex flex-wrap items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px]'>
{allowedEmails.map((email) => (
<div
key={email}
className='flex items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[2px] text-[12px]'
>
<span>{email}</span>
<button
type='button'
onClick={() => setAllowedEmails(allowedEmails.filter((e) => e !== email))}
className='text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</button>
</div>
))}
<input
type='text'
placeholder={
allowedEmails.length > 0 ? 'Add another' : 'Enter emails or @domain.com'
<TagInput
items={emailItems}
onAdd={(value) => {
const trimmed = value.trim().toLowerCase()
if (!trimmed || emailItems.some((item) => item.value === trimmed)) {
return false
}
className='min-w-[150px] flex-1 border-none bg-transparent p-0 text-sm outline-none placeholder:text-[var(--text-muted)]'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
const value = (e.target as HTMLInputElement).value.trim()
if (value && !allowedEmails.includes(value)) {
setAllowedEmails([...allowedEmails, value])
clearError('emails')
;(e.target as HTMLInputElement).value = ''
}
}
}}
/>
</div>
const isDomainPattern = trimmed.startsWith('@')
const isValid = isDomainPattern || trimmed.includes('@')
setEmailItems((prev) => [...prev, { value: trimmed, isValid }])
if (isValid) {
clearError('emails')
}
return isValid
}}
onRemove={(_value, index) => {
setEmailItems((prev) => prev.filter((_, i) => i !== index))
}}
placeholder='Enter emails or @domain.com'
placeholderWithTags='Add another'
/>
{errors.emails && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.emails}</p>
)}

View File

@@ -225,7 +225,7 @@ export function DocumentTagEntry({
*/
const renderTagHeader = (tag: DocumentTag, index: number) => (
<div
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={() => toggleCollapse(tag.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>

View File

@@ -373,7 +373,7 @@ function InputMappingField({
)}
>
<div
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={onToggleCollapse}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>

View File

@@ -218,7 +218,7 @@ export function KnowledgeTagFilters({
*/
const renderFilterHeader = (filter: TagFilter, index: number) => (
<div
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={() => toggleCollapse(filter.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>

View File

@@ -163,7 +163,7 @@ export function FieldFormat({
*/
const renderFieldHeader = (field: Field, index: number) => (
<div
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={() => toggleCollapse(field.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>

View File

@@ -2156,7 +2156,7 @@ export function ToolInput({
>
<div
className={cn(
'flex items-center justify-between gap-[8px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
'flex items-center justify-between gap-[8px] rounded-t-[4px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
(isCustomTool || hasToolBody) && 'cursor-pointer'
)}
onClick={() => {

View File

@@ -318,7 +318,7 @@ export function VariablesInput({
)}
>
<div
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={() => toggleCollapse(assignment.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { ArrowUp, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
BubbleChatClose,
BubbleChatPreview,
Button,
Copy,
@@ -427,7 +428,7 @@ export function Panel() {
variant={isChatOpen ? 'active' : 'default'}
onClick={() => setIsChatOpen(!isChatOpen)}
>
<BubbleChatPreview />
{isChatOpen ? <BubbleChatClose /> : <BubbleChatPreview />}
</Button>
</div>

View File

@@ -1240,7 +1240,7 @@ export function Terminal() {
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear console</span>
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</>
@@ -1605,7 +1605,7 @@ export function Terminal() {
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear console</span>
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
)}

View File

@@ -1,44 +0,0 @@
import React from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
export interface EmailTagProps {
email: string
onRemove: () => void
disabled?: boolean
isInvalid?: boolean
isSent?: boolean
}
export const EmailTag = React.memo<EmailTagProps>(
({ email, onRemove, disabled, isInvalid, isSent }) => (
<div
className={cn(
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
isInvalid
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
: 'border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='max-w-[200px] truncate'>{email}</span>
{isSent && <span className='text-[11px] text-[var(--text-tertiary)]'>sent</span>}
{!disabled && !isSent && (
<button
type='button'
onClick={onRemove}
className={cn(
'flex-shrink-0 transition-colors focus:outline-none',
isInvalid
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
)}
aria-label={`Remove ${email}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
</button>
)}
</div>
)
)
EmailTag.displayName = 'EmailTag'

View File

@@ -1,4 +1,3 @@
export * from './email-tag'
export * from './permission-selector'
export * from './permissions-table'
export * from './permissions-table-skeleton'

View File

@@ -1,25 +1,25 @@
'use client'
import React, { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
TagInput,
type TagItem,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { API_ENDPOINTS } from '@/stores/constants'
import type { PermissionType, UserPermissions } from './components'
import { EmailTag, PermissionsTable } from './components'
import { PermissionsTable } from './components'
const logger = createLogger('InviteModal')
@@ -40,9 +40,7 @@ interface PendingInvitation {
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [inputValue, setInputValue] = useState('')
const [emails, setEmails] = useState<string[]>([])
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
@@ -79,7 +77,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
} = useWorkspacePermissionsContext()
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
const hasNewInvites = emails.length > 0 || inputValue.trim()
const validEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
const hasNewInvites = validEmails.length > 0
const fetchPendingInvitations = useCallback(async () => {
if (!workspaceId) return
@@ -134,14 +133,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const validation = quickValidateEmail(normalized)
const isValid = validation.isValid
if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
if (emailItems.some((item) => item.value === normalized)) {
return false
}
const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized)
if (hasPendingInvitation) {
setErrorMessage(`${normalized} already has a pending invitation`)
setInputValue('')
return false
}
@@ -150,52 +148,43 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
)
if (isExistingMember) {
setErrorMessage(`${normalized} is already a member of this workspace`)
setInputValue('')
return false
}
if (session?.user?.email && session.user.email.toLowerCase() === normalized) {
setErrorMessage('You cannot invite yourself')
setInputValue('')
return false
}
if (!isValid) {
setInvalidEmails((prev) => [...prev, normalized])
setInputValue('')
return false
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
if (isValid) {
setErrorMessage(null)
setUserPermissions((prev) => [
...prev,
{
email: normalized,
permissionType: 'read',
},
])
}
setErrorMessage(null)
setEmails((prev) => [...prev, normalized])
setUserPermissions((prev) => [
...prev,
{
email: normalized,
permissionType: 'read',
},
])
setInputValue('')
return true
return isValid
},
[emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email]
[emailItems, pendingInvitations, workspacePermissions?.users, session?.user?.email]
)
const removeEmail = useCallback(
(index: number) => {
const emailToRemove = emails[index]
setEmails((prev) => prev.filter((_, i) => i !== index))
setUserPermissions((prev) => prev.filter((user) => user.email !== emailToRemove))
const removeEmailItem = useCallback(
(_value: string, index: number, isValid?: boolean) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid ?? itemToRemove?.isValid) {
setUserPermissions((prev) => prev.filter((user) => user.email !== itemToRemove?.value))
}
},
[emails]
[emailItems]
)
const removeInvalidEmail = useCallback((index: number) => {
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}, [])
const handlePermissionChange = useCallback(
(identifier: string, permissionType: PermissionType) => {
const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier)
@@ -472,57 +461,15 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
[workspaceId, userPerms.canAdmin, resendCooldowns]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
e.preventDefault()
addEmail(inputValue)
}
if (e.key === 'Backspace' && !inputValue) {
if (invalidEmails.length > 0) {
removeInvalidEmail(invalidEmails.length - 1)
} else if (emails.length > 0) {
removeEmail(emails.length - 1)
}
}
},
[inputValue, addEmail, invalidEmails, emails, removeInvalidEmail, removeEmail]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
let addedCount = 0
pastedEmails.forEach((email) => {
if (addEmail(email)) {
addedCount++
}
})
if (addedCount === 0 && pastedEmails.length === 1) {
setInputValue(inputValue + pastedEmails[0])
}
},
[addEmail, inputValue]
)
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (inputValue.trim()) {
addEmail(inputValue)
}
// Clear messages at start of submission
setErrorMessage(null)
setSuccessMessage(null)
if (emails.length === 0 || !workspaceId) {
if (validEmails.length === 0 || !workspaceId) {
return
}
@@ -532,7 +479,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const failedInvites: string[] = []
const results = await Promise.all(
emails.map(async (email) => {
validEmails.map(async (email) => {
try {
const userPermission = userPermissions.find((up) => up.email === email)
const permissionType = userPermission?.permissionType || 'read'
@@ -553,9 +500,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const data = await response.json()
if (!response.ok) {
if (!invalidEmails.includes(email)) {
failedInvites.push(email)
}
failedInvites.push(email)
if (data.error) {
setErrorMessage(data.error)
@@ -566,16 +511,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
return true
} catch {
if (!invalidEmails.includes(email)) {
failedInvites.push(email)
}
failedInvites.push(email)
return false
}
})
)
const successCount = results.filter(Boolean).length
const successfulEmails = emails.filter((_, index) => results[index])
const successfulEmails = validEmails.filter((_, index) => results[index])
if (successCount > 0) {
if (successfulEmails.length > 0) {
@@ -605,17 +548,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
}
fetchPendingInvitations()
setInputValue('')
if (failedInvites.length > 0) {
setEmails(failedInvites)
setEmailItems(failedInvites.map((email) => ({ value: email, isValid: true })))
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
} else {
setEmails([])
setEmailItems([])
setUserPermissions([])
}
setInvalidEmails([])
setShowSent(true)
setTimeout(() => {
@@ -631,23 +571,12 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setIsSubmitting(false)
}
},
[
inputValue,
addEmail,
emails,
workspaceId,
userPermissions,
invalidEmails,
fetchPendingInvitations,
onOpenChange,
]
[validEmails, workspaceId, userPermissions, fetchPendingInvitations]
)
const resetState = useCallback(() => {
// Batch state updates using React's automatic batching in React 18+
setInputValue('')
setEmails([])
setInvalidEmails([])
setEmailItems([])
setUserPermissions([])
setPendingInvitations([])
setIsPendingInvitationsLoading(false)
@@ -718,55 +647,21 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
tabIndex={-1}
readOnly
/>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] focus-within:outline-none'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => removeInvalidEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
isInvalid={true}
/>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => removeEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
/>
))}
<Input
id='invite-field'
name='invite_search_field'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => inputValue.trim() && addEmail(inputValue)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className={cn(
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
</div>
<TagInput
id='invite-field'
name='invite_search_field'
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={removeEmailItem}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: 'Enter emails'
}
placeholderWithTags='Add email'
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
/>
</div>
{errorMessage && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>{errorMessage}</p>

View File

@@ -107,6 +107,15 @@ export {
TableHeader,
TableRow,
} from './table/table'
export {
Tag,
TagInput,
type TagInputProps,
type TagItem,
type TagProps,
tagInputVariants,
tagVariants,
} from './tag-input/tag-input'
export { Textarea } from './textarea/textarea'
export { TimePicker, type TimePickerProps, timePickerVariants } from './time-picker/time-picker'
export { Tooltip } from './tooltip/tooltip'

View File

@@ -0,0 +1,340 @@
/**
* A tag input component for managing a list of tags with validation support.
*
* @example
* ```tsx
* import { TagInput, type TagItem } from '@/components/emcn'
*
* const [items, setItems] = useState<TagItem[]>([])
*
* <TagInput
* items={items}
* onAdd={(value) => {
* const isValid = isValidEmail(value)
* setItems(prev => [...prev, { value, isValid }])
* return isValid
* }}
* onRemove={(value, index) => {
* setItems(prev => prev.filter((_, i) => i !== index))
* }}
* placeholder="Enter emails"
* />
* ```
*/
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Plus, X } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
/**
* Variant styles for the Tag component.
* No vertical padding to fit within the input's natural line height without causing expansion.
* Uses colored badge-style variants (blue for valid, red for invalid).
*/
const tagVariants = cva(
'flex w-auto cursor-default items-center gap-[3px] rounded-[4px] px-[4px] font-medium font-sans text-[13px] leading-[20px] transition-colors',
{
variants: {
variant: {
default: 'bg-[#bfdbfe] text-[#1d4ed8] dark:bg-[rgba(59,130,246,0.2)] dark:text-[#93c5fd]',
invalid:
'bg-[#fecaca] text-[var(--text-error)] dark:bg-[#551a1a] dark:text-[var(--text-error)]',
},
},
defaultVariants: {
variant: 'default',
},
}
)
/**
* Props for the Tag component.
*/
export interface TagProps extends VariantProps<typeof tagVariants> {
/** The tag value to display */
value: string
/** Callback when remove button is clicked */
onRemove?: () => void
/** Whether the tag is disabled */
disabled?: boolean
/** Additional class names */
className?: string
/** Optional suffix content (e.g., "sent" label) */
suffix?: React.ReactNode
}
/**
* A single tag badge with optional remove button.
*/
const Tag = React.memo(function Tag({
value,
onRemove,
disabled,
variant,
className,
suffix,
}: TagProps) {
return (
<div className={cn(tagVariants({ variant }), className)}>
<span className='max-w-[200px] truncate'>{value}</span>
{suffix}
{!disabled && onRemove && (
<button
type='button'
onClick={onRemove}
className={cn(
'flex-shrink-0 opacity-80 transition-opacity hover:opacity-100 focus:outline-none',
variant === 'invalid'
? 'text-[var(--text-error)]'
: 'text-[#1d4ed8] dark:text-[#93c5fd]'
)}
aria-label={`Remove ${value}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.5px]' />
</button>
)}
</div>
)
})
/**
* Variant styles for the TagInput container.
* Matches the Input component styling exactly for consistent height.
*/
const tagInputVariants = cva(
'scrollbar-hide flex w-full cursor-text flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-colors focus-within:outline-none dark:bg-[var(--surface-5)]',
{
variants: {
variant: {
default: '',
},
},
defaultVariants: {
variant: 'default',
},
}
)
/**
* Represents a tag item with its value and validity status.
*/
export interface TagItem {
value: string
isValid: boolean
}
/**
* Props for the TagInput component.
*/
export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
/** Array of tag items with value and validity status */
items: TagItem[]
/**
* Callback when a new tag is added.
* Return true if the value was valid and added, false if invalid.
*/
onAdd: (value: string) => boolean
/** Callback when a tag is removed (receives value, index, and isValid) */
onRemove: (value: string, index: number, isValid: boolean) => void
/** Placeholder text for the input */
placeholder?: string
/** Placeholder text when there are existing tags */
placeholderWithTags?: string
/** Whether the input is disabled */
disabled?: boolean
/** Additional class names for the container */
className?: string
/** Additional class names for the input */
inputClassName?: string
/** Maximum height for the container (defaults to 128px / max-h-32) */
maxHeight?: string
/** HTML id for the input element */
id?: string
/** HTML name for the input element */
name?: string
/** Whether to auto-focus the input */
autoFocus?: boolean
/** Custom keys that trigger tag addition (defaults to Enter, comma, space) */
triggerKeys?: string[]
/** Optional render function for tag suffix content */
renderTagSuffix?: (value: string, index: number) => React.ReactNode
}
/**
* An input component for managing a list of tags.
*
* @remarks
* - Maintains consistent height with standard Input component
* - Supports keyboard navigation (Enter/comma/space to add, Backspace to remove)
* - Handles paste with multiple values separated by whitespace, commas, or semicolons
* - Displays invalid values with error styling
*/
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(
{
items,
onAdd,
onRemove,
placeholder = 'Enter values',
placeholderWithTags = 'Add another',
disabled = false,
className,
inputClassName,
maxHeight = 'max-h-32',
id,
name,
autoFocus = false,
triggerKeys = ['Enter', ',', ' '],
renderTagSuffix,
variant,
},
ref
) => {
const [inputValue, setInputValue] = React.useState('')
const internalRef = React.useRef<HTMLInputElement>(null)
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
const hasItems = items.length > 0
React.useEffect(() => {
if (autoFocus && inputRef.current) {
inputRef.current.focus()
}
}, [autoFocus, inputRef])
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (triggerKeys.includes(e.key) && inputValue.trim()) {
e.preventDefault()
onAdd(inputValue.trim())
setInputValue('')
}
if (e.key === 'Backspace' && !inputValue && items.length > 0) {
const lastItem = items[items.length - 1]
onRemove(lastItem.value, items.length - 1, lastItem.isValid)
}
},
[inputValue, triggerKeys, onAdd, items, onRemove]
)
const handlePaste = React.useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedValues = pastedText.split(/[\s,;]+/).filter(Boolean)
let addedCount = 0
pastedValues.forEach((value) => {
if (onAdd(value.trim())) {
addedCount++
}
})
if (addedCount === 0 && pastedValues.length === 1) {
setInputValue(inputValue + pastedValues[0])
}
},
[onAdd, inputValue]
)
const handleBlur = React.useCallback(() => {
if (inputValue.trim()) {
onAdd(inputValue.trim())
setInputValue('')
}
}, [inputValue, onAdd])
const handleContainerClick = React.useCallback(() => {
inputRef.current?.focus()
}, [inputRef])
return (
<div
className={cn(tagInputVariants({ variant }), maxHeight, className)}
onClick={handleContainerClick}
>
{items.map((item, index) => (
<Tag
key={`item-${index}`}
value={item.value}
variant={item.isValid ? 'default' : 'invalid'}
onRemove={() => onRemove(item.value, index, item.isValid)}
disabled={disabled}
suffix={item.isValid ? renderTagSuffix?.(item.value, index) : undefined}
/>
))}
<div
className={cn(
'flex items-center',
inputValue.trim() &&
cn(tagVariants({ variant: 'default' }), 'gap-0 py-0 pr-0 pl-[4px] opacity-80')
)}
>
<div className='relative inline-flex'>
{inputValue.trim() && (
<span
className='invisible whitespace-pre font-medium font-sans text-[13px] leading-[20px]'
aria-hidden='true'
>
{inputValue}
</span>
)}
<input
ref={inputRef}
id={id}
name={name}
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
placeholder={hasItems ? placeholderWithTags : placeholder}
size={hasItems ? placeholderWithTags?.length || 10 : placeholder?.length || 12}
className={cn(
'border-none bg-transparent font-medium font-sans outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50',
inputValue.trim()
? 'absolute top-0 left-0 h-full w-full p-0 text-[13px] text-inherit leading-[20px]'
: 'w-auto min-w-0 p-0 text-foreground text-sm',
inputClassName
)}
disabled={disabled}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
</div>
{inputValue.trim() && (
<button
type='button'
onMouseDown={(e) => {
e.preventDefault()
if (inputValue.trim()) {
onAdd(inputValue.trim())
setInputValue('')
inputRef.current?.focus()
}
}}
className='flex items-center px-[3px] opacity-80 transition-opacity hover:opacity-100 focus:outline-none'
disabled={disabled}
aria-label='Add tag'
>
<Plus className='h-[12px] w-[12px]' />
</button>
)}
</div>
</div>
)
}
)
TagInput.displayName = 'TagInput'
export { Tag, TagInput, tagInputVariants, tagVariants }

View File

@@ -57,9 +57,37 @@ const Content = React.forwardRef<
))
Content.displayName = TooltipPrimitive.Content.displayName
interface ShortcutProps {
/** The keyboard shortcut keys to display (e.g., "⌘D", "⌘K") */
keys: string
/** Optional additional class names */
className?: string
/** Optional children to display before the shortcut */
children?: React.ReactNode
}
/**
* Displays a keyboard shortcut within tooltip content.
*
* @example
* ```tsx
* <Tooltip.Content>
* <Tooltip.Shortcut keys="⌘D">Clear console</Tooltip.Shortcut>
* </Tooltip.Content>
* ```
*/
const Shortcut = ({ keys, className, children }: ShortcutProps) => (
<span className={cn('flex items-center gap-[8px]', className)}>
{children && <span>{children}</span>}
<span className='opacity-70'>{keys}</span>
</span>
)
Shortcut.displayName = 'Tooltip.Shortcut'
export const Tooltip = {
Root,
Trigger,
Content,
Provider,
Shortcut,
}

View File

@@ -0,0 +1,28 @@
import type { SVGProps } from 'react'
/**
* BubbleChatClose icon component - chat bubble with solid eye to indicate closing
* @param props - SVG properties including className, fill, etc.
*/
export function BubbleChatClose(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='14'
height='14'
viewBox='0 0 14 14'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M10.208 0.729126C9.46715 0.729126 8.8405 1.03967 8.3672 1.39158C7.89351 1.74377 7.54598 2.15728 7.35252 2.41564C7.28999 2.49672 7.14551 2.68425 7.14551 2.91663C7.14551 3.149 7.28999 3.33653 7.35252 3.41761C7.54598 3.67598 7.89351 4.08948 8.3672 4.44168C8.8405 4.79358 9.46715 5.10413 10.208 5.10413C10.9489 5.10413 11.5755 4.79358 12.0488 4.44168C12.5225 4.08948 12.87 3.67597 13.0635 3.41761C13.126 3.33653 13.2705 3.149 13.2705 2.91663C13.2705 2.68425 13.126 2.49672 13.0635 2.41564C12.87 2.15728 12.5225 1.74377 12.0488 1.39158C11.5755 1.03967 10.9489 0.729126 10.208 0.729126Z'
fill='currentColor'
/>
<circle cx='10.208' cy='2.91663' r='0.875' fill='currentColor' />
<path
d='M7 0.729431C7.25201 0.729431 7.37829 0.728973 7.43066 0.789978C7.45461 0.817872 7.46841 0.85201 7.47168 0.888611C7.47864 0.968713 7.38194 1.06792 7.18848 1.26556C6.95871 1.50031 6.7804 1.72067 6.65723 1.8847L6.65137 1.89154C6.58026 1.98206 6.27051 2.37696 6.27051 2.91693C6.27061 3.45637 6.57996 3.85045 6.65137 3.94135L6.65723 3.94818C6.88003 4.24495 7.28385 4.72574 7.8457 5.14349C8.41007 5.56311 9.21632 5.97934 10.208 5.97943C11.1998 5.97943 12.0069 5.56316 12.5713 5.14349C12.7408 5.01748 12.8259 4.95516 12.9023 4.97064C12.9183 4.9739 12.9338 4.97875 12.9482 4.98627C13.0174 5.02234 13.0412 5.1157 13.0889 5.3017C13.2075 5.76414 13.2705 6.24824 13.2705 6.74701C13.2705 10.0885 10.4444 12.7656 7 12.7656C6.59416 12.766 6.18957 12.7281 5.79102 12.6533C5.65266 12.6273 5.56443 12.6115 5.49902 12.6025C5.45024 12.595 5.40122 12.613 5.38281 12.623C5.31602 12.6547 5.22698 12.7019 5.09082 12.7744C4.25576 13.2184 3.28146 13.3758 2.3418 13.2011C2.19004 13.1729 2.06398 13.0667 2.01074 12.9218C1.95752 12.777 1.98471 12.6148 2.08203 12.4951C2.35492 12.1594 2.543 11.7545 2.62598 11.3193C2.64818 11.1997 2.59719 11.0372 2.44141 10.8788C1.38277 9.80387 0.729492 8.34981 0.729492 6.74701C0.729632 3.4056 3.55564 0.729431 7 0.729431ZM4.66699 6.41693C4.34485 6.41693 4.08305 6.67781 4.08301 6.99994C4.08301 7.32211 4.34483 7.58295 4.66699 7.58295H4.67188C4.99404 7.58295 5.25488 7.32211 5.25488 6.99994C5.25484 6.67781 4.99401 6.41693 4.67188 6.41693H4.66699ZM6.99707 6.41693C6.67508 6.4171 6.41411 6.67792 6.41406 6.99994C6.41406 7.322 6.67505 7.58278 6.99707 7.58295H7.00293C7.32495 7.58278 7.58594 7.322 7.58594 6.99994C7.58589 6.67792 7.32492 6.4171 7.00293 6.41693H6.99707ZM9.33105 6.41693C9.00892 6.41693 8.74712 6.67781 8.74707 6.99994C8.74707 7.32211 9.00889 7.58295 9.33105 7.58295H9.33594C9.6581 7.58295 9.91895 7.32211 9.91895 6.99994C9.9189 6.67781 9.65808 6.41693 9.33594 6.41693H9.33105Z'
fill='currentColor'
/>
</svg>
)
}

View File

@@ -1,3 +1,4 @@
export { BubbleChatClose } from './bubble-chat-close'
export { BubbleChatPreview } from './bubble-chat-preview'
export { Card } from './card'
export { ChevronDown } from './chevron-down'

View File

@@ -43,7 +43,7 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
autoConnect: data.autoConnect ?? true,
showTrainingControls: data.showTrainingControls ?? false,
superUserModeEnabled: data.superUserModeEnabled ?? true,
theme: data.theme || 'system',
theme: data.theme || 'dark',
telemetryEnabled: data.telemetryEnabled ?? true,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,

View File

@@ -38,6 +38,6 @@ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') {
* Gets the current theme from next-themes localStorage
*/
export function getThemeFromNextThemes(): 'system' | 'light' | 'dark' {
if (typeof window === 'undefined') return 'system'
return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'system'
if (typeof window === 'undefined') return 'dark'
return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'dark'
}