diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index f02ff3946e5..0342cadb8cb 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -6,14 +6,14 @@ paths: # EMCN Components -Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons come from the `@sim/emcn/icons` subpath, and CSS modules from their file path. Never deep-import other component subpaths. The **chip family** is the platform's primary chrome — always reach for it over the legacy primitives it is progressively replacing (`Input`→`ChipInput`, `Textarea`→`ChipTextarea`, `Modal`→`ChipModal`, `Select`/`Combobox`→`ChipSelect`/`ChipCombobox`/`ChipDropdown`, `Switch`→`ChipSwitch`, date field→`ChipDatePicker`). For context/action menus the canonical control is `DropdownMenu` — the standard menu (not a chip, and never a hand-rolled popover). +Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons come from the `@sim/emcn/icons` subpath, and CSS modules from their file path. Never deep-import other component subpaths. The **chip family** is the platform's primary chrome — always reach for it over the legacy primitives it is progressively replacing (`Input`→`ChipInput`, `Textarea`→`ChipTextarea`, `Modal`→`ChipModal`, `Select`/`Combobox`→`ChipSelect`/`ChipCombobox`, `Switch`→`ChipSwitch`, date field→`ChipDatePicker`). For context/action menus the canonical control is `DropdownMenu` — the standard menu (not a chip, and never a hand-rolled popover). ## Chip chrome — single source of truth Never hand-roll the chip pill from raw class strings (they go stale). Compose from the canonical sources: - **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells), and the row-state pair `chipHoverSurfaceClass` / `chipActiveSurfaceClass` (hover vs. selected — mutually exclusive, so a selected row holds its surface through hover; every hand-rolled row imports these rather than restating the literals). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. -- **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipDropdown`, `ChipSelect`, `ChipSwitch`) reuses it for visual parity. +- **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipSelect`, `ChipDatePicker`) reuses it for visual parity. Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border-1` border. @@ -24,15 +24,15 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`Chip` / `ChipLink`** — the pill button (` + ), +})) +vi.mock('@/app/(auth)/components/auth-button-classes', () => ({ AUTH_TEXT_LINK: '' })) +vi.mock('@/components/auth/public-auth-header', () => ({ + PublicAuthHeader: ({ title }: { title: string }) =>

{title}

, +})) +vi.mock('@/app/f/[token]/public-file-auth-shell', () => ({ + PublicFileAuthShell: ({ children }: { children: ReactNode }) =>
{children}
, +})) +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) })) +vi.mock('@/hooks/queries/chats', () => ({ + useChatEmailOtpRequest: () => ({ mutateAsync: mocks.chatRequest, isPending: false }), + useChatEmailOtpVerify: () => ({ mutateAsync: mocks.chatVerify, isPending: false }), +})) +vi.mock('@/hooks/queries/public-shares', () => ({ + usePublicFileOtpRequest: () => ({ mutateAsync: mocks.fileRequest, isPending: false }), + usePublicFileOtpVerify: () => ({ mutateAsync: mocks.fileVerify, isPending: false }), +})) + +import EmailAuth from '@/app/(interfaces)/chat/components/auth/email/email-auth' +import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' + +let root: Root +let container: HTMLDivElement + +function changeInput(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function button(label: string) { + const found = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === label + ) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} + +function expectOtpInvalid(invalid: boolean) { + expect(container.querySelector('[data-testid="otp-code"]')?.getAttribute('aria-invalid')).toBe( + String(invalid) + ) + const slots = container.querySelectorAll('[data-otp-slot]') + expect(slots).toHaveLength(6) + for (const slot of slots) expect(slot.getAttribute('data-invalid')).toBe(String(invalid)) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.chatRequest.mockResolvedValue({}) + mocks.chatVerify.mockResolvedValue({}) + mocks.fileRequest.mockResolvedValue({}) + mocks.fileVerify.mockResolvedValue({}) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('OTP error provenance', () => { + it('keeps the chat code valid on resend failure and marks only a failed verification invalid', async () => { + act(() => root.render()) + act(() => + changeInput(container.querySelector('#email')!, 'member@example.com') + ) + await act(async () => button('Continue').click()) + + mocks.chatRequest.mockRejectedValueOnce(new Error('Delivery failed')) + await act(async () => button('Resend').click()) + expect(container.textContent).toContain('Delivery failed') + expectOtpInvalid(false) + + mocks.chatVerify.mockRejectedValueOnce(new Error('Incorrect code')) + await act(async () => + changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456') + ) + expect(container.textContent).toContain('Incorrect code') + expectOtpInvalid(true) + }) + + it('keeps the public-file code valid on resend failure and marks only a failed verification invalid', async () => { + act(() => root.render()) + act(() => + changeInput(container.querySelector('#email')!, 'member@example.com') + ) + await act(async () => button('Continue').click()) + + mocks.fileRequest.mockRejectedValueOnce(new Error('Delivery failed')) + await act(async () => button('Resend').click()) + expect(container.textContent).toContain('Delivery failed') + expectOtpInvalid(false) + + mocks.fileVerify.mockRejectedValueOnce(new Error('Incorrect code')) + await act(async () => + changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456') + ) + expect(container.textContent).toContain('Incorrect code') + expectOtpInvalid(true) + }) +}) diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 88d1c2e9d67..3e45a82f3fb 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -1,7 +1,7 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' import { AuthFormMessage, @@ -84,14 +84,16 @@ function VerificationForm({

- + {OTP_SLOTS.map((index) => ( - + ))} @@ -149,8 +151,8 @@ function VerificationFormFallback() { return (
-
-
+
+
) diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx index 405a06bc0e7..8393900a280 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx @@ -8,10 +8,10 @@ export default function ChatLoading() {
- - + +
- +
@@ -24,16 +24,16 @@ export default function ChatLoading() {
- +
- +
- +
diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx index 8710f405311..190dfbaf917 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -1,9 +1,10 @@ 'use client' import { useEffect, useState } from 'react' -import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn' +import { ChipInput, cn, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { PublicAuthHeader } from '@/components/auth/public-auth-header' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { AuthSubmitButton } from '@/app/(auth)/components' import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes' @@ -33,13 +34,17 @@ const validateEmailField = (emailValue: string): string[] => { export default function EmailAuth({ identifier }: EmailAuthProps) { const [email, setEmail] = useState('') - const [authError, setAuthError] = useState(null) + const [authError, setAuthError] = useState<{ + kind: 'verification' | 'request' + message: string + } | null>(null) const [emailErrors, setEmailErrors] = useState([]) const hasEmailError = emailErrors.length > 0 const [showOtpVerification, setShowOtpVerification] = useState(false) const [otpValue, setOtpValue] = useState('') const [countdown, setCountdown] = useState(0) + const isInvalidOtp = authError?.kind === 'verification' const requestOtp = useChatEmailOtpRequest(identifier) const verifyOtp = useChatEmailOtpVerify(identifier) @@ -88,7 +93,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { await verifyOtp.mutateAsync({ email, otp: codeToVerify }) } catch (error) { logger.error('Error verifying OTP:', error) - setAuthError(toError(error).message || 'Invalid verification code') + setAuthError({ + kind: 'verification', + message: toError(error).message || 'Invalid verification code', + }) } } @@ -101,7 +109,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { setOtpValue('') } catch (error) { logger.error('Error resending OTP:', error) - setAuthError(toError(error).message || 'Failed to resend verification code') + setAuthError({ + kind: 'request', + message: toError(error).message || 'Failed to resend verification code', + }) setCountdown(0) } } @@ -110,16 +121,14 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
-
-

- {showOtpVerification ? 'Verify Your Email' : 'Email Verification'} -

-

- {showOtpVerification + -

+ : 'This chat requires email verification' + } + />
{!showOtpVerification ? ( @@ -134,7 +143,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
- {hasEmailError && (
@@ -183,15 +191,12 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { } }} disabled={verifyOtp.isPending} - className={cn('gap-2', authError && 'otp-error')} + className={cn('gap-2', isInvalidOtp && 'otp-error')} + aria-invalid={isInvalidOtp} > {[0, 1, 2, 3, 4, 5].map((index) => ( - + ))} @@ -199,7 +204,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { {authError && (
-

{authError}

+

{authError.message}

)} @@ -231,7 +236,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {

-
+
-
+
- +
) diff --git a/apps/sim/app/(interfaces)/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx index b2c6c8b48f1..af48ca6908a 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -130,7 +130,7 @@ export const ChatInput: React.FC<{ {uploadErrors.length > 0 && (
{uploadErrors.map((error, idx) => ( - + {error} ))} @@ -172,7 +172,7 @@ export const ChatInput: React.FC<{ {attachedFiles.map((file) => ( -
+
{file.dataUrl ? ( - + {file.name.split('.').pop()}
@@ -215,7 +215,7 @@ export const ChatInput: React.FC<{ onKeyDown={handleKeyDown} placeholder={isDragOver ? 'Drop files here...' : 'Enter a message...'} rows={1} - className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[15px] text-[var(--text-primary)] leading-[24px] caret-[var(--text-primary)] outline-hidden [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden' + className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[var(--text-primary)] text-base leading-[24px] caret-[var(--text-primary)] outline-hidden [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden' />
diff --git a/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx b/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx index 838051a1817..0ac57babb30 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx @@ -4,7 +4,7 @@ import { Button } from '@sim/emcn' interface PublicChatActionButtonProps extends Omit< ComponentProps, - 'variant' | 'size' | 'iconSize' | 'iconPadding' | 'className' + 'variant' | 'size' | 'iconSize' | 'iconPadding' | 'className' | 'shape' > { variant: 'primary' | 'quiet' 'aria-label': string @@ -12,6 +12,6 @@ interface PublicChatActionButtonProps /** Public chat's circular composer action, retaining its primary and quiet palettes. */ export const PublicChatActionButton = forwardRef( - (props, ref) =>
diff --git a/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx index 9a355614557..b9dd835af01 100644 --- a/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx @@ -72,7 +72,8 @@ export function ChatMessageContainer({ +
Refresh
@@ -800,7 +803,7 @@ export default function ResumeExecutionPage({ {/* Main Layout */}
{/* Pause Points List */} -
+
@@ -832,17 +835,17 @@ export default function ResumeExecutionPage({ {/* Detail Panel */}
{loadingDetail && !selectedDetail ? ( -
+
Loading…
) : !selectedContextId ? ( -
+
Select a pause point
) : !selectedDetail ? ( -
+
Could not load details @@ -850,7 +853,7 @@ export default function ResumeExecutionPage({ ) : (
{/* Status Header */} -
+

@@ -868,7 +871,7 @@ export default function ResumeExecutionPage({

{selectedDetail.pausePoint.automaticResumeWaitingReason && ( -
+

{selectedDetail.pausePoint.automaticResumeWaitingReason} @@ -881,7 +884,7 @@ export default function ResumeExecutionPage({ {/* Already resolved - show form fields with submitted values */} {selectedStatus === 'resumed' || selectedStatus === 'failed' ? ( -

+
@@ -932,7 +935,7 @@ export default function ResumeExecutionPage({ <> {/* Display Data */} {responseStructureRows.length > 0 ? ( -
+
@@ -958,7 +961,7 @@ export default function ResumeExecutionPage({
) : ( -
+
@@ -972,7 +975,7 @@ export default function ResumeExecutionPage({ {/* Resume Form */} {isHumanMode && hasInputFormat ? ( -
+
@@ -1001,7 +1004,7 @@ export default function ResumeExecutionPage({
) : ( -
+
@@ -1020,7 +1023,8 @@ export default function ResumeExecutionPage({ placeholder='{"example": "value"}' rows={6} spellCheck={false} - className='min-h-[180px] font-mono' + monospace + className='min-h-[180px]' />
diff --git a/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.tsx b/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.tsx index 4fbba3d3750..5787f2c8560 100644 --- a/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.tsx +++ b/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.tsx @@ -9,7 +9,7 @@ type LandingCtaSize = 'compact' | 'default' | 'display' export type LandingCtaSection = PostHogEventMap['landing_cta_clicked']['section'] -interface LandingCtaLinkProps extends Omit { +interface LandingCtaLinkProps extends Omit { size?: LandingCtaSize variant?: 'primary' | 'outline' /** Adds the animated chevron used by demo actions. */ diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/enterprise-menu-preview/enterprise-menu-preview.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/enterprise-menu-preview/enterprise-menu-preview.tsx index 5d187bb7345..0d3ca4fb9d1 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/enterprise-menu-preview/enterprise-menu-preview.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/enterprise-menu-preview/enterprise-menu-preview.tsx @@ -1,4 +1,4 @@ -import { Chip, ChipDropdown, cn } from '@sim/emcn' +import { Chip, ChipSelect, cn } from '@sim/emcn' import { Building, MoreHorizontal, Plus } from '@sim/emcn/icons' import { MenuPreviewFrame } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/menu-preview-frame' import { MenuPreviewHeader } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/menu-preview-header/menu-preview-header' @@ -47,11 +47,14 @@ export function EnterpriseMenuPreview({ layout = 'menu' }: EnterpriseMenuPreview image={null} status='' roleControl={ - } menu={} diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx index 534fa557cdc..787a852a5e1 100644 --- a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx +++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useId, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' -import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn' +import { Chip, ChipInput, ChipSelect, ChipTextarea, Label } from '@sim/emcn' import { Check } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { @@ -286,7 +286,10 @@ export function ContactForm() { /> - - - option.value === (organizationRoles[member.email] ?? member.role))?.label ?? 'Owner'}`} value={organizationRoles[member.email] ?? member.role} options={ @@ -76,7 +79,7 @@ export function EnterpriseMembersPreview() { setOrganizationRoles((roles) => ({ ...roles, [member.email]: role })) } disabled={member.role === 'owner'} - matchTriggerWidth={false} + dropdownWidth='content' /> } /> @@ -95,7 +98,10 @@ export function EnterpriseMembersPreview() { image={null} status='' roleControl={ - option.value === (workspaceRoles[member.email] ?? (member.role === 'owner' ? 'admin' : 'write')))?.label}`} value={ workspaceRoles[member.email] ?? (member.role === 'owner' ? 'admin' : 'write') @@ -105,7 +111,7 @@ export function EnterpriseMembersPreview() { setWorkspaceRoles((roles) => ({ ...roles, [member.email]: role })) } disabled={member.role === 'owner'} - matchTriggerWidth={false} + dropdownWidth='content' /> } /> diff --git a/apps/sim/app/(landing)/logs/components/log-history-preview/log-history-preview.tsx b/apps/sim/app/(landing)/logs/components/log-history-preview/log-history-preview.tsx index 2e1dba49f5f..af9574dcb6f 100644 --- a/apps/sim/app/(landing)/logs/components/log-history-preview/log-history-preview.tsx +++ b/apps/sim/app/(landing)/logs/components/log-history-preview/log-history-preview.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useId, useRef, useState } from 'react' -import { Badge, Chip, ChipDropdown, ChipInput } from '@sim/emcn' +import { Badge, Chip, ChipInput, ChipSelect } from '@sim/emcn' import { Library, ListFilter, Search, X } from '@sim/emcn/icons' import { MenuPreviewHeader, @@ -109,13 +109,16 @@ export function LogHistoryPreview() { onChange={(event) => setQuery(event.target.value)} className='mr-auto min-w-0 flex-1' /> - option.value === filter)?.label ?? filter}`} value={filter} options={FILTERS} onChange={setFilter} - matchTriggerWidth={false} + dropdownWidth='content' />
diff --git a/apps/sim/app/(landing)/tables/components/tables-records-preview/components/lead-record-detail/lead-record-detail.tsx b/apps/sim/app/(landing)/tables/components/tables-records-preview/components/lead-record-detail/lead-record-detail.tsx index 9eb6378eb52..7236265ea4b 100644 --- a/apps/sim/app/(landing)/tables/components/tables-records-preview/components/lead-record-detail/lead-record-detail.tsx +++ b/apps/sim/app/(landing)/tables/components/tables-records-preview/components/lead-record-detail/lead-record-detail.tsx @@ -1,11 +1,11 @@ import { useState } from 'react' import { - ChipDropdown, ChipModal, ChipModalBody, ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSelect, } from '@sim/emcn' import { TagIcon, TypeNumber, TypeText } from '@sim/emcn/icons' import type { LeadRecord } from '@/app/(landing)/tables/components/tables-records-preview/data' @@ -65,7 +65,11 @@ export function LeadRecordDetail({ record, onClose, onSave }: LeadRecordDetailPr } > -
-
+
{/* `pl` offsets the trailing letter-space `tracking` adds after the last glyph, which would otherwise pull the code left of optical center. */} - + {request.pairing}
diff --git a/apps/sim/app/cli/auth/loading.tsx b/apps/sim/app/cli/auth/loading.tsx index e6a96d20127..911ae79010e 100644 --- a/apps/sim/app/cli/auth/loading.tsx +++ b/apps/sim/app/cli/auth/loading.tsx @@ -11,11 +11,11 @@ import { AuthShell } from '@/app/(auth)/components' export function CliAuthLoading() { return (
- - - - - + + + + +
) } diff --git a/apps/sim/app/f/[token]/public-file-auth-shell.tsx b/apps/sim/app/f/[token]/public-file-auth-shell.tsx index 24da9e8d470..8953a5a5895 100644 --- a/apps/sim/app/f/[token]/public-file-auth-shell.tsx +++ b/apps/sim/app/f/[token]/public-file-auth-shell.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { PublicAuthHeader } from '@/components/auth/public-auth-header' import { SupportFooter } from '@/app/(auth)/components/support-footer' import { LogoShell } from '@/app/(landing)/components/logo-shell' @@ -17,14 +18,7 @@ export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAut return ( }>
-
-

- {title} -

-

- {subtitle} -

-
+
{children}
diff --git a/apps/sim/app/f/[token]/public-file-auth.tsx b/apps/sim/app/f/[token]/public-file-auth.tsx index c0682b0d123..cf63af1b2fc 100644 --- a/apps/sim/app/f/[token]/public-file-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-auth.tsx @@ -1,11 +1,10 @@ 'use client' import { useState } from 'react' -import { cn, Input, Label } from '@sim/emcn' -import { Eye, EyeOff } from '@sim/emcn/icons' +import { Label } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' -import { AuthSubmitButton } from '@/app/(auth)/components' +import { AuthSubmitButton, PasswordInput } from '@/app/(auth)/components' import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell' import { usePublicFileAuth } from '@/hooks/queries/public-shares' @@ -21,7 +20,6 @@ export function PublicFileAuth({ token }: PublicFileAuthProps) { const router = useRouter() const authenticate = usePublicFileAuth(token) const [password, setPassword] = useState('') - const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState(null) const handleAuthenticate = async () => { @@ -49,35 +47,21 @@ export function PublicFileAuth({ token }: PublicFileAuthProps) { >
-
- { - setPassword(e.target.value) - setError(null) - }} - className={cn( - 'pr-10', - error && 'border-[var(--text-error)] focus:border-[var(--text-error)]' - )} - /> - -
+ { + setPassword(e.target.value) + setError(null) + }} + error={Boolean(error)} + /> {error ?

{error}

: null}
diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx index 71a5c564492..198fea32ae5 100644 --- a/apps/sim/app/f/[token]/public-file-email-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn' +import { ChipInput, cn, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { normalizeEmail } from '@sim/utils/string' import { useRouter } from 'next/navigation' @@ -28,8 +28,12 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { const [email, setEmail] = useState('') const [otp, setOtp] = useState('') const [sent, setSent] = useState(false) - const [error, setError] = useState(null) + const [error, setError] = useState<{ + kind: 'verification' | 'request' + message: string + } | null>(null) const [countdown, setCountdown] = useState(0) + const isInvalidOtp = error?.kind === 'verification' useEffect(() => { if (countdown <= 0) return @@ -39,7 +43,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { const sendCode = async () => { if (!quickValidateEmail(normalizeEmail(email)).isValid) { - setError('Please enter a valid email address.') + setError({ kind: 'request', message: 'Please enter a valid email address.' }) return } setError(null) @@ -48,7 +52,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { setSent(true) setOtp('') } catch (err) { - setError(getErrorMessage(err, 'Failed to send verification code')) + setError({ + kind: 'request', + message: getErrorMessage(err, 'Failed to send verification code'), + }) } } @@ -59,7 +66,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { await verifyOtp.mutateAsync({ email: normalizeEmail(email), otp: code }) router.refresh() } catch (err) { - setError(getErrorMessage(err, 'Invalid verification code')) + setError({ kind: 'verification', message: getErrorMessage(err, 'Invalid verification code') }) } } @@ -71,7 +78,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { setError(null) } catch (err) { setCountdown(0) - setError(getErrorMessage(err, 'Failed to resend verification code')) + setError({ + kind: 'request', + message: getErrorMessage(err, 'Failed to resend verification code'), + }) } } @@ -90,7 +100,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { >
- - {error ?

{error}

: null} + {error ?

{error.message}

: null}
{[0, 1, 2, 3, 4, 5].map((i) => ( - + ))}
- {error ?

{error}

: null} + {error ? ( +

{error.message}

+ ) : null}
-
+
@@ -187,7 +187,7 @@ export default function PlaygroundPage() { aria-label={isDarkMode ? 'Light mode' : 'Dark mode'} variant='default' onClick={toggleDarkMode} - className='size-8 p-0' + iconSize='roomy' > {isDarkMode ? : } @@ -307,12 +307,6 @@ export default function PlaygroundPage() { - - - - - - @@ -344,14 +338,8 @@ export default function PlaygroundPage() { JavaScript - - - Option 1 - Option 2 - - - - + + Option 1 Option 2 @@ -410,9 +398,6 @@ export default function PlaygroundPage() { Amber - - Teal - Cyan @@ -425,7 +410,6 @@ export default function PlaygroundPage() { Small Medium - Large @@ -525,10 +509,7 @@ export default function PlaygroundPage() { Medium (16px) - - - Large (20px) - + @@ -879,7 +860,7 @@ export default function PlaygroundPage() { - + Item 1 diff --git a/apps/sim/app/unsubscribe/loading.tsx b/apps/sim/app/unsubscribe/loading.tsx index 5f625c75bbd..e5a3aa10a8f 100644 --- a/apps/sim/app/unsubscribe/loading.tsx +++ b/apps/sim/app/unsubscribe/loading.tsx @@ -3,11 +3,11 @@ import { Skeleton } from '@sim/emcn' export default function UnsubscribeLoading() { return (
- - - - - + + + + +
) } diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx index b5f265fd18e..34083d2b4ee 100644 --- a/apps/sim/app/unsubscribe/unsubscribe.tsx +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -1,13 +1,12 @@ 'use client' import { Suspense } from 'react' -import { Chip, cn, Loader } from '@sim/emcn' +import { Chip, Loader } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useSearchParams } from 'next/navigation' import type { UnsubscribeType } from '@/lib/api/contracts/user' import { AuthSubmitButton } from '@/app/(auth)/components' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' -import { InviteLayout } from '@/app/invite/components' +import { InviteHeading, InviteLayout } from '@/app/invite/components' import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe' function UnsubscribeContent() { @@ -39,10 +38,9 @@ function UnsubscribeContent() { if (loading) { return ( -
-

Loading

+

Validating your unsubscribe link…

-
+
@@ -53,12 +51,9 @@ function UnsubscribeContent() { if (error) { return ( -
-

- Invalid Unsubscribe Link -

+

{error}

-
+
window.history.back()} loadingLabel=''> @@ -72,15 +67,12 @@ function UnsubscribeContent() { if (data?.isTransactional) { return ( -
-

- Important Account Emails -

+

Transactional emails like password resets, account confirmations, and security alerts cannot be unsubscribed from as they contain essential information for your account.

-
+
window.close()} loadingLabel=''> @@ -94,15 +86,12 @@ function UnsubscribeContent() { if (unsubscribed) { return ( -
-

- Successfully Unsubscribed -

+

You have been unsubscribed from our emails. You will stop receiving emails within 48 hours.

-
+
window.close()} loadingLabel=''> @@ -117,15 +106,12 @@ function UnsubscribeContent() { return ( -
-

- Email Preferences -

+

Choose which emails you'd like to stop receiving.

{data?.email}

-
+
{data?.currentPreferences.unsubscribeMarketing ? 'Unsubscribed from Marketing' @@ -167,7 +155,9 @@ function UnsubscribeContent() { isAlreadyUnsubscribedFromAll || data?.currentPreferences.unsubscribeUpdates } - className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')} + variant='outline' + size='lg' + align='center' > {data?.currentPreferences.unsubscribeUpdates ? 'Unsubscribed from Updates' @@ -182,7 +172,9 @@ function UnsubscribeContent() { isAlreadyUnsubscribedFromAll || data?.currentPreferences.unsubscribeNotifications } - className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')} + variant='outline' + size='lg' + align='center' > {data?.currentPreferences.unsubscribeNotifications ? 'Unsubscribed from Notifications' @@ -205,10 +197,9 @@ export default function Unsubscribe() { -
-

Loading

+

Validating your unsubscribe link…

-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx new file mode 100644 index 00000000000..b91cc7e471e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx @@ -0,0 +1,159 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CodeSearchOverlay, + type CodeSearchOverlayProps, +} from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay' + +let host: HTMLDivElement +let root: Root +const inputRef = createRef() + +const callbacks = { + onQueryChange: vi.fn(), + onPrevious: vi.fn(), + onNext: vi.fn(), + onClose: vi.fn(), +} +const parentClick = vi.fn() + +function renderOverlay(props: Partial = {}) { + act(() => + root.render( +
+ +
+ ) + ) + const overlay = host.firstElementChild?.firstElementChild as HTMLDivElement + const input = overlay.querySelector('input') as HTMLInputElement + return { overlay, input } +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() +}) + +describe('CodeSearchOverlay', () => { + it('shares the floating chrome and routes query, navigation, and close actions', () => { + const { overlay, input } = renderOverlay() + expect(overlay.className).toContain('h-[34px]') + expect(overlay.className).toContain('rounded-sm bg-[var(--surface-1)]') + expect(overlay.className).toContain('top-0 right-0') + expect(overlay.getAttribute('role')).toBe('presentation') + expect(inputRef.current).toBe(input) + expect(input.getAttribute('aria-label')).toBe('Search code') + expect(input.value).toBe('error') + expect(overlay.textContent).toContain('2/3') + const tally = overlay.querySelector('[aria-live="polite"][aria-atomic="true"]') + expect(tally?.textContent).toBe('2/3') + + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + act(() => { + setter?.call(input, 'failed') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(callbacks.onQueryChange).toHaveBeenCalledWith('failed') + act(() => { + overlay.querySelector('[aria-label="Previous match"]')?.click() + overlay.querySelector('[aria-label="Next match"]')?.click() + overlay.querySelector('[aria-label="Close search"]')?.click() + }) + expect(callbacks.onPrevious).toHaveBeenCalledTimes(1) + expect(callbacks.onNext).toHaveBeenCalledTimes(1) + expect(callbacks.onClose).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + + renderOverlay({ matchCount: 0, currentMatchIndex: 0 }) + expect(tally?.textContent).toBe('0/0') + }) + + it('keeps preview search compact in a floating overlay with a usable input ref', () => { + const { overlay, input } = renderOverlay({ + inputKind: 'plain', + className: 'top-10 right-[8px]', + }) + expect(overlay.getAttribute('role')).toBe('presentation') + expect(overlay.className).toContain('top-10 right-[8px]') + expect(overlay.hasAttribute('data-toolbar-root')).toBe(false) + expect(input.parentElement?.className).toContain('h-[23px]') + expect(inputRef.current).toBe(input) + + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + act(() => { + setter?.call(input, 'preview') + input.dispatchEvent(new Event('input', { bubbles: true })) + overlay.querySelector('[aria-label="Next match"]')?.click() + }) + expect(callbacks.onQueryChange).toHaveBeenCalledWith('preview') + expect(callbacks.onNext).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('retains the attached terminal edge, marker, wider tally, and disabled navigation', () => { + const { overlay, input } = renderOverlay({ + appearance: 'attached', + inputKind: 'plain', + className: 'top-[30px] right-[8px]', + query: '', + matchCount: 0, + currentMatchIndex: 0, + }) + expect(overlay.className).toContain('rounded-b-sm border-t-0 bg-[var(--bg)]') + expect(overlay.getAttribute('data-toolbar-root')).toBe('true') + expect(overlay.getAttribute('data-search-active')).toBe('true') + expect(overlay.hasAttribute('role')).toBe(false) + expect(input.parentElement?.className).toContain('h-[23px]') + expect(input.parentElement?.className).toContain('w-[94px]') + expect(input.className).toContain('text-caption') + expect(overlay.textContent).toContain('No results') + expect(overlay.querySelector('span.w-\\[58px\\]')).not.toBeNull() + const previous = overlay.querySelector('[aria-label="Previous match"]') + const next = overlay.querySelector('[aria-label="Next match"]') + const close = overlay.querySelector('[aria-label="Close search"]') + expect(previous?.disabled).toBe(true) + expect(next?.disabled).toBe(true) + expect(close?.disabled).toBe(false) + expect(previous?.className).toContain('-m-1.5') + expect(previous?.querySelector('svg')?.getAttribute('class')).toContain('size-[14px]') + act(() => { + previous?.click() + next?.click() + close?.click() + }) + expect(callbacks.onPrevious).not.toHaveBeenCalled() + expect(callbacks.onNext).not.toHaveBeenCalled() + expect(callbacks.onClose).toHaveBeenCalledTimes(1) + }) + + it('shows the compact no-results tally for other code panels', () => { + const { overlay } = renderOverlay({ matchCount: 0, currentMatchIndex: 0 }) + expect(overlay.textContent).toContain('0/0') + expect(overlay.getAttribute('data-toolbar-root')).toBeNull() + expect( + overlay.querySelector('[aria-label="Previous match"]')?.disabled + ).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx new file mode 100644 index 00000000000..d8750794cf5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx @@ -0,0 +1,100 @@ +import type { ChangeEvent, Ref } from 'react' +import { Button, ChipInput, cn } from '@sim/emcn' +import { ArrowDown, ArrowUp, X } from '@sim/emcn/icons' + +export interface CodeSearchOverlayProps { + /** The attached terminal panel has a joined lower edge and wider result tally. */ + appearance?: 'floating' | 'attached' + /** Position relative to the owning code panel. */ + className: string + /** Logs use the 30px chip field; previews and terminal output use compact search. */ + inputKind: 'chip' | 'plain' + inputRef: Ref + query: string + onQueryChange: (query: string) => void + matchCount: number + currentMatchIndex: number + onPrevious: () => void + onNext: () => void + onClose: () => void +} + +/** Shared controls for searching a Code.Viewer without owning its search state. */ +export function CodeSearchOverlay({ + appearance = 'floating', + className, + inputKind, + inputRef, + query, + onQueryChange, + matchCount, + currentMatchIndex, + onPrevious, + onNext, + onClose, +}: CodeSearchOverlayProps) { + const attached = appearance === 'attached' + const inputProps = { + ref: inputRef, + type: 'text', + value: query, + onChange: (event: ChangeEvent) => onQueryChange(event.target.value), + placeholder: 'Search...', + 'aria-label': 'Search code', + } as const + const actionProps = { + type: 'button' as const, + variant: 'ghost' as const, + iconPadding: attached ? ('md' as const) : ('sm' as const), + className: attached ? '-m-1.5' : undefined, + } + const iconClass = attached ? 'size-[14px]' : 'size-[12px]' + + return ( +
event.stopPropagation()} + data-toolbar-root={attached ? true : undefined} + data-search-active={attached ? true : undefined} + > + {inputKind === 'chip' ? ( + + ) : ( + + )} + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 + ? `${currentMatchIndex + 1}/${matchCount}` + : attached + ? 'No results' + : '0/0'} + + + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx index ceb2b0b709b..6ec4de4377e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx @@ -129,7 +129,8 @@ export const FindBar = memo(function FindBar({ type='button' variant='ghost' size='icon' - className='size-6 shrink-0' + iconSize='compact' + className='shrink-0' aria-label={showReplace ? 'Hide replace' : 'Show replace'} aria-expanded={showReplace} onClick={() => setShowReplace((visible) => !visible)} @@ -179,7 +180,8 @@ export const FindBar = memo(function FindBar({ type='button' variant='ghost' size='icon' - className='size-6 shrink-0' + iconSize='compact' + className='shrink-0' aria-label='Previous match' title='Previous match (Shift+Enter)' disabled={!navEnabled} @@ -191,7 +193,8 @@ export const FindBar = memo(function FindBar({ type='button' variant='ghost' size='icon' - className='size-6 shrink-0' + iconSize='compact' + className='shrink-0' aria-label='Next match' title='Next match (Enter)' disabled={!navEnabled} @@ -203,7 +206,8 @@ export const FindBar = memo(function FindBar({ type='button' variant='ghost' size='icon' - className='size-6 shrink-0' + iconSize='compact' + className='shrink-0' aria-label='Close find' title='Close (Esc)' onClick={onClose} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts index ee37ca45a8c..a7bc8d07fc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts @@ -11,7 +11,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' * the label inherit the app font and match the row it was lifted from. */ const DRAG_GHOST_STYLE = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:var(--radius-lg);font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' const DRAG_GHOST_LABEL_STYLE = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 57d83213e0e..59bf626240d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -2,7 +2,6 @@ export { isResourceListEmpty, resourceListState, } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' -export { ResourceNotFound } from '@/app/workspace/[workspaceId]/components/resource/resource-not-found' export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx index f27e865a6a1..ac53da16d2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx @@ -21,7 +21,7 @@ const { hostContext, mockUseOrganizationBilling, mockUseAdminWorkspaces, mockMut ) vi.mock('@sim/emcn', () => ({ - ChipDropdown: () =>
, + ChipSelect: () =>
, ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, ChipModalError: ({ children }: { children: ReactNode }) =>
{children}
, diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index f2efc1446eb..d51c928c90f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -2,14 +2,14 @@ import { useCallback, useMemo, useState } from 'react' import { - ChipDropdown, - type ChipDropdownOption, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSelect, + type ChipSelectOption, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' @@ -160,7 +160,7 @@ export function InviteModal({ { enabled: open && isOrganizationInvite && !organizationOnly } ) - const workspaceOptions = useMemo(() => { + const workspaceOptions = useMemo(() => { if (!isOrganizationInvite) { return workspaceId ? [{ value: workspaceId, label: workspaceName ?? 'This workspace' }] : [] } @@ -316,12 +316,15 @@ export function InviteModal({ {!organizationOnly && ( <> - ({ })) vi.mock('@sim/emcn', () => ({ + Button: ({ + variant, + iconSize, + ...props + }: ComponentProps<'button'> & { variant?: string; iconSize?: string }) => + {copied ? 'Copied message' : 'Copy message'} @@ -180,27 +180,29 @@ export const MessageActions = memo(function MessageActions({ <> - + Good response - + Bad response @@ -209,15 +211,16 @@ export const MessageActions = memo(function MessageActions({ {canFork && ( - + Fork in new chat diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx index 7ded34f10d0..65442f93310 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -2,6 +2,7 @@ import type { ComponentType } from 'react' import { + BulkActionBar, BulkActionButton, cn, DropdownMenu, @@ -91,57 +92,48 @@ export function ResourceActionBar({ className )} > -
- - {exceedsLimit - ? `${selectedCount} selected · select ${maxSelectable} or fewer` - : `${selectedCount} selected`} - -
- {onDownload && ( - - )} - {onMove && moveOptions && ( - - - - - - - - - - Move - - - {renderMoveOptions(moveOptions, onMove)} - - - )} - {onDelete && ( - - )} -
-
+ + {exceedsLimit + ? `${selectedCount} selected · select ${maxSelectable} or fewer` + : `${selectedCount} selected`} + + } + > + {onDownload && ( + + )} + {onMove && moveOptions && ( + + + + + + + + + + Move + + + {renderMoveOptions(moveOptions, onMove)} + + + )} + {onDelete && ( + + )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx deleted file mode 100644 index fa6007c8e02..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { ComponentType } from 'react' - -interface ResourceNotFoundProps { - icon: ComponentType<{ className?: string }> - title: string - description: string -} - -/** - * Full-page screen for a resource that could not be loaded and has no shell left to - * draw — a knowledge base or a document that was deleted or moved. - * - * Distinct from the `emptyState` slot on {@link Resource.Table}: that one keeps the - * chrome and reports a failure *within* a page that still exists. This replaces the - * page, so it is only right when the thing the page is about is the thing that is gone. - */ -export function ResourceNotFound({ icon: Icon, title, description }: ResourceNotFoundProps) { - return ( -
- -
-

{title}

-

{description}

-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 95bf5999af8..b20af9cd3c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -536,12 +536,10 @@ const Pagination = memo(function Pagination({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx index c99164e58e0..762b82c0391 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx @@ -24,8 +24,8 @@ function ChartErrorCard({ message, content }: { message: string; content: string return (
- chart - {message} + chart + {message}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css
index 43e6809375e..43c821a2ce0 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css
@@ -54,14 +54,14 @@
   padding: 0.5rem 0.75rem;
   text-align: left;
   vertical-align: top;
-  font-size: 14px;
+  font-size: var(--text-sm);
   line-height: 1.5rem;
 }
 
 .rich-markdown-prose th,
 .document-table th {
   background: var(--surface-4);
-  font-weight: 600;
+  font-weight: var(--font-weight-semibold);
 }
 
 /* Cell editors are invisible until focused: they take the cell's own typography and color. */
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts
index fda1bbb36da..ff2c67485b5 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts
@@ -141,8 +141,8 @@ describe('document-table chrome is shared with markdown tables', () => {
     const { th, td } = mountTable('document-table')
 
     expect(getComputedStyle(td).getPropertyValue('padding-left')).toBe('0.75rem')
-    expect(getComputedStyle(td).getPropertyValue('font-size')).toBe('14px')
-    expect(getComputedStyle(th).getPropertyValue('font-weight')).toBe('600')
+    expect(getComputedStyle(td).getPropertyValue('font-size')).toBe('var(--text-sm)')
+    expect(getComputedStyle(th).getPropertyValue('font-weight')).toBe('var(--font-weight-semibold)')
   })
 
   it('one rule draws the cell border for both roots', () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index 52407bbf750..c30a44d78d8 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -427,7 +427,7 @@ const MediaPreview = memo(function MediaPreview({
       
-

{file.name}

+

{file.name}

{blobUrl && ( // biome-ignore lint/a11y/useMediaCaption: audio from workspace files diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx index 10019822f72..81d2f981af1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx @@ -49,9 +49,9 @@ function MermaidSourcePreview({ return (
- mermaid + mermaid {(isRendering || status) && ( - + {isRendering ? 'Rendering…' : status} )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx index 598e330ca5a..3b255d082a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx @@ -43,7 +43,7 @@ interface PdfViewerCoreProps { function PdfError({ error }: { error: string }) { return (
-

Failed to preview PDF

+

Failed to preview PDF

{error}

) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx index ed585e4c382..1b5ca5f1932 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx @@ -18,7 +18,7 @@ export const UnsupportedPreview = memo(function UnsupportedPreview({ name }: { n return (
-

+

Preview not available{ext ? ` for .${ext} files` : ' for this file'}

@@ -31,7 +31,9 @@ export const UnsupportedPreview = memo(function UnsupportedPreview({ name }: { n export function PreviewError({ label, error }: { label: string; error: string }) { return (

-

Failed to preview {label}

+

+ Failed to preview {label} +

{error}

) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index d6623e648d6..76f43919127 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -282,11 +282,13 @@ export function ResizableImageView({ node, selected, editor, getPos }: ReactNode type='button' variant='ghost' size='icon' + iconSize={{ base: 'touch', sm: 'roomy' }} + iconPadding='sm' aria-label='Resize image' onPointerDown={startResize} - className='absolute right-0 bottom-0 flex size-10 cursor-nwse-resize touch-none items-end justify-end p-1 sm:size-8' + className='absolute right-0 bottom-0 cursor-nwse-resize touch-none items-end justify-end' > - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-mark-precedence.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-mark-precedence.test.ts index a2edbbae3e5..f5fae887e8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-mark-precedence.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-mark-precedence.test.ts @@ -102,11 +102,11 @@ describe('mark WRAPPING a link (`text`) — link color still }) describe('each mark keeps its own non-color styling even when link-colored', () => { - it('bold link keeps font-weight 600', () => { + it('bold link keeps the central semibold weight', () => { const root = mount('bold link') const strong = root.querySelector('strong') as HTMLElement expect(colorOf(strong)).toBe(LINK_COLOR) - expect(getComputedStyle(strong).fontWeight).toBe('600') + expect(getComputedStyle(strong).fontWeight).toBe('var(--font-weight-semibold)') }) it('italic link keeps font-style italic', () => { @@ -144,7 +144,7 @@ describe('multiple marks stacked together with a link', () => { expect(colorOf(em)).toBe(LINK_COLOR) expect(colorOf(strong)).toBe(LINK_COLOR) expect(getComputedStyle(em).fontStyle).toBe('italic') - expect(getComputedStyle(strong).fontWeight).toBe('600') + expect(getComputedStyle(strong).fontWeight).toBe('var(--font-weight-semibold)') }) it('bold + italic + strikethrough + link: link color wins at every nesting level', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts index 918934aa0e1..353c615db0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -17,7 +17,7 @@ export const SUGGESTION_SCROLL_CLASS = 'max-h-[240px] scroll-py-1.5 overflow-y-a /** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. The `img` rules * size custom-block image icons (rendered as ``, so the `svg` rules never reach them). */ export const SUGGESTION_ITEM_CLASS = - 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-hidden transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)] [&_img]:pointer-events-none [&_img]:size-[14px] [&_img]:shrink-0' + 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-hidden transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)] [&_img]:pointer-events-none [&_img]:size-[14px] [&_img]:shrink-0' /** A group heading above a run of rows. */ export const SUGGESTION_GROUP_LABEL_CLASS = diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.test.tsx index 340a43c1e50..4ce6c90a039 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.test.tsx @@ -41,7 +41,8 @@ describe('ToolbarButton', () => { ) const button = host.querySelector('button[aria-label="Add to Chat"]') - expect(button?.className).toContain('size-[28px]') + expect(button?.classList.contains('size-10')).toBe(true) + expect(button?.classList.contains('sm:size-7')).toBe(true) expect(button?.querySelector('svg')?.className.baseVal).toContain('size-[12px]') }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx index 4bdcb5b19d8..7d2aa9f6a58 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx @@ -1,5 +1,5 @@ import type { ComponentType, SVGProps } from 'react' -import { Button, cn, Tooltip } from '@sim/emcn' +import { Button, Tooltip } from '@sim/emcn' interface ToolbarButtonProps { /** Any SVG icon component, e.g. from `@sim/emcn/icons`. */ @@ -28,17 +28,15 @@ export function ToolbarButton({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index ea4058895e7..0713cf189c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -3,7 +3,7 @@ import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap import { FootnoteDef, RawHtmlBlock } from './raw-markdown-snippet-schema' const BLOCK_CONTROL_CLASS = - 'pointer-events-none absolute top-1.5 right-2 select-none rounded-md bg-[var(--surface-4)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] uppercase tracking-wide opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100' + 'pointer-events-none absolute top-1.5 right-2 select-none rounded-md bg-[var(--surface-4)] px-1.5 py-0.5 text-micro text-[var(--text-muted)] uppercase tracking-wide opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100' /** Badge text per block node type name — kept here rather than threaded through node options since * {@link NodeViewProps} exposes no options/extension reference to the rendering component. */ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index b6a13b7e214..8d4ec7b8244 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -18,8 +18,8 @@ outline: none; color: var(--text-primary); font-family: var(--font-inter); - font-size: 15px; - font-weight: 400; + font-size: var(--text-base); + font-weight: var(--font-weight-normal); line-height: 25px; letter-spacing: 0; overflow-wrap: anywhere; @@ -28,7 +28,7 @@ .rich-markdown-nodes img { max-width: 100%; height: auto; - border-radius: 8px; + border-radius: var(--radius-lg); border: var(--border-width) solid var(--border); } @@ -37,7 +37,7 @@ .rich-markdown-nodes .ProseMirror-selectednode { outline: 2px solid var(--brand-secondary); outline-offset: 2px; - border-radius: 4px; + border-radius: var(--radius-sm); } /* An image is its own framed element; ring the image itself so the indicator hugs the picture @@ -73,7 +73,7 @@ .rich-markdown-prose h4, .rich-markdown-prose h5, .rich-markdown-prose h6 { - font-weight: 600; + font-weight: var(--font-weight-semibold); line-height: 1.3; color: var(--text-primary); } @@ -117,7 +117,7 @@ * the surrounding color, not reset it; omitting `color` here lets normal CSS inheritance do that * correctly in every context, including ones this file doesn't know about yet. */ .rich-markdown-prose strong { - font-weight: 600; + font-weight: var(--font-weight-semibold); } .rich-markdown-prose em { @@ -226,7 +226,7 @@ height: 16px; margin: 0; border: var(--border-width) solid var(--border-1); - border-radius: 3px; + border-radius: var(--radius-sm); background: transparent; cursor: pointer; } @@ -258,7 +258,7 @@ font-family: var(--font-martian-mono, ui-monospace, monospace); font-size: 0.875em; background: var(--surface-5); - border-radius: 4px; + border-radius: var(--radius-sm); padding: 0.125rem 0.375rem; } @@ -279,7 +279,7 @@ .rich-markdown-prose pre, .rich-markdown-prose .mermaid-diagram-frame { background: var(--surface-5); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 1rem; overflow-x: auto; } @@ -317,7 +317,7 @@ .rich-markdown-prose pre code { background: none; padding: 0; - font-size: 13px; + font-size: var(--text-small); line-height: 21px; } @@ -341,12 +341,12 @@ } .rich-markdown-nodes .raw-markdown-block { - border-radius: 8px; + border-radius: var(--radius-lg); padding: 0.75rem 1rem; } .rich-markdown-nodes .raw-markdown-inline { - border-radius: 4px; + border-radius: var(--radius-sm); padding: 0.0625rem 0.3rem; } @@ -373,7 +373,7 @@ .rich-markdown-nodes img.rich-leaf-in-selection { outline: 2px solid var(--selection-bg); outline-offset: 2px; - border-radius: 4px; + border-radius: var(--radius-sm); } /* Borders, padding, typography, and header fill come from document-table.css — the chrome shared @@ -442,7 +442,7 @@ .rich-markdown-nodes mark { background-color: color-mix(in srgb, var(--color-amber-400) 40%, transparent); color: inherit; - border-radius: 2px; + border-radius: var(--radius-xs); padding: 0 0.1em; margin: 0 -0.1em; box-decoration-break: clone; @@ -458,8 +458,8 @@ * set their own 600, so only body text and the placeholder are affected. */ .rich-markdown-field-prose { - font-size: 14px; - font-weight: 400; + font-size: var(--text-sm); + font-weight: var(--font-weight-normal); line-height: 22px; } @@ -548,7 +548,7 @@ left: -1px; width: 8px; height: 5px; - border-radius: 2px 2px 2px 0; + border-radius: var(--radius-xs) var(--radius-xs) var(--radius-xs) 0; background-color: var(--caret-color); transition: opacity 0.2s ease; } @@ -586,10 +586,10 @@ max-width: 10rem; overflow: hidden; padding: 0.1rem 0.35rem; - border-radius: 2px 2px 2px 0; + border-radius: var(--radius-xs) var(--radius-xs) var(--radius-xs) 0; /* 11px = the `text-xs` the canvas/tables presence tags use, for pixel parity. */ - font-size: 11px; - font-weight: 500; + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); line-height: 1.2; white-space: nowrap; text-overflow: ellipsis; @@ -611,12 +611,12 @@ .rich-markdown-nodes .collaboration-carets__caret--flip .collaboration-carets__label { left: auto; right: -1px; - border-radius: 2px 2px 0 2px; + border-radius: var(--radius-xs) var(--radius-xs) 0 var(--radius-xs); } .rich-markdown-nodes .collaboration-carets__selection { background-color: color-mix(in srgb, var(--caret-color) 20%, transparent); - border-radius: 2px; + border-radius: var(--radius-xs); pointer-events: none; } @@ -625,7 +625,7 @@ .rich-markdown-nodes .rich-find-match { background-color: var(--highlight-match-bg); color: var(--highlight-match-text); - border-radius: 2px; + border-radius: var(--radius-xs); } /* The active hit is a stronger fill of the same hue, never a ring: an outline drawn diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 9cb16f9a489..99c7d1981c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Avatar, - Button, + Chip, ChipCombobox, ChipConfirmModal, Columns2, @@ -2061,13 +2061,9 @@ function FilesContent() { )} {hasActiveFilters && ( - + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx index 5bf1c53ac4d..925e6451401 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx @@ -127,7 +127,7 @@ function BrowserAgentFavicon({ url, canLoad }: BrowserAgentFaviconProps) { referrerPolicy='no-referrer' alt='' className={cn( - 'size-full rounded-[3px]', + 'size-full rounded-sm', status !== 'loaded' && 'pointer-events-none absolute opacity-0' )} onLoad={() => setStatus('loaded')} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx index ad1308ca832..de5beb4425f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx @@ -8,7 +8,7 @@ import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/component /** The action-row button, matching the copy and vote buttons beside it with room for a count. */ const BUTTON_CLASSES = - 'flex h-[26px] items-center gap-1 rounded-[6px] px-1.5 text-[var(--text-icon)] text-caption transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-hidden data-[state=open]:bg-[var(--surface-active)] data-[state=open]:hover-hover:bg-[var(--surface-active)]' + 'flex h-[26px] items-center gap-1 rounded-md px-1.5 text-[var(--text-icon)] text-caption transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-hidden data-[state=open]:bg-[var(--surface-active)] data-[state=open]:hover-hover:bg-[var(--surface-active)]' interface MessageSourcesProps { sources: readonly SourceTagData[] diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 8ad547fda69..c775747ff85 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1218,7 +1218,9 @@ function MessageContentInner({ <>
- Stopped by user + + Stopped by user +
{actions &&
{actionsRow}
} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/mothership-chat-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/mothership-chat-skeleton.tsx index 9b32c21b1dd..8c4714a5c1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/mothership-chat-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/mothership-chat-skeleton.tsx @@ -27,24 +27,24 @@ export function MothershipChatSkeleton({ return (
- +
- - - - + + + +
- +
- - - + + +
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index e18c64f374f..cc84dfebcf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -143,7 +143,7 @@ const LAYOUT_STYLES = { rowGap: 'pb-6', userRow: 'flex flex-col items-end gap-[6px] pt-3', attachmentWidth: 'max-w-[70%]', - userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2', + userBubble: 'max-w-[70%] overflow-hidden rounded-2xl bg-[var(--surface-5)] px-3.5 py-2', assistantRow: 'group/msg', footer: 'shrink-0 px-[24px] pb-[16px]', footerInner: 'mx-auto max-w-chat', @@ -155,7 +155,7 @@ const LAYOUT_STYLES = { rowGap: 'pb-4', userRow: 'flex flex-col items-end gap-[6px] pt-2', attachmentWidth: 'max-w-[85%]', - userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2', + userBubble: 'max-w-[85%] overflow-hidden rounded-2xl bg-[var(--surface-5)] px-3 py-2', assistantRow: 'group/msg', footer: 'shrink-0 px-3 pb-3', footerInner: '', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx index ed3ae9e2eb1..a694dc21271 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { BrowserDownloadInfo } from '@sim/desktop-bridge' import { - Button, cn, DropdownMenu, DropdownMenuContent, @@ -18,6 +17,7 @@ import { showBrowserDownloadInFolder, showBrowserDownloadsMenu, } from '@/lib/browser-agent/transport' +import { BrowserToolbarButton } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-toolbar-button' /** Aggregate byte progress for the toolbar rail; unknown-size downloads are ignored. */ export function aggregateDownloadPercent(downloads: BrowserDownloadInfo[]): number | null { @@ -127,14 +127,11 @@ export function BrowserDownloads({ scopeId, open, requestOpen, onClose }: Browse }} > - + Downloads diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx index a3f9e23198d..8313c2c549e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx @@ -106,7 +106,8 @@ export function BrowserFindBar({ inputRef, onClose, scopeId }: BrowserFindBarPro size='sm' aria-label='Previous match' disabled={!result?.matches} - className='size-[24px] shrink-0 p-0' + iconSize='compact-fixed' + className='shrink-0' onClick={() => step('back')} > @@ -117,7 +118,8 @@ export function BrowserFindBar({ inputRef, onClose, scopeId }: BrowserFindBarPro size='sm' aria-label='Next match' disabled={!result?.matches} - className='size-[24px] shrink-0 p-0' + iconSize='compact-fixed' + className='shrink-0' onClick={() => step('forward')} > @@ -127,7 +129,8 @@ export function BrowserFindBar({ inputRef, onClose, scopeId }: BrowserFindBarPro variant='ghost-secondary' size='sm' aria-label='Close find bar' - className='size-[24px] shrink-0 p-0' + iconSize='compact-fixed' + className='shrink-0' onClick={dismiss} > diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx index 199aa2b8be9..6c8299d27a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx @@ -148,7 +148,7 @@ export function BrowserPageIssueView({ issue, onReload, focusRecovery }: Browser

{copy.headline} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index e3170db1f84..e516805a8b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -12,7 +12,6 @@ import type { import { isBrowserTheme } from '@sim/browser-protocol' import type { BrowserAddToChatPayload, DesktopAppearanceTheme } from '@sim/desktop-bridge' import { - Button, ChipConfirmModal, ChipInput, chipVariants, @@ -76,6 +75,7 @@ import { useBrowserPanelOcclusion, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion' import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice' +import { BrowserToolbarButton } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-toolbar-button' import { buildOmniboxSuggestions, googleSearchUrl, @@ -111,7 +111,7 @@ function BrowserSuggestionIcon({ suggestion }: { suggestion: UrlSuggestion }) { setFailed(true)} /> ) @@ -1006,38 +1006,26 @@ export function BrowserSession({
- - - + {/* URL bar: Enter navigates the agent browser. */} )} + , + 'variant' | 'size' | 'iconSize' | 'iconPadding' | 'type' + > { + 'aria-label': string +} + +/** Browser navigation and utility action; forwards menu-anchor refs and native events. */ +export const BrowserToolbarButton = forwardRef( + ({ className, ...props }, ref) => ( +
), - ChipDropdown: ({ - value, - onChange, + ChipSelect: ({ + multiSelectValues, + onMultiSelectChange, options, }: { - value: string[] - onChange: (value: string[]) => void + multiSelectValues: string[] + onMultiSelectChange: (value: string[]) => void options: { value: string; label: string }[] }) => ( + + + ) + }) + } + + render(true) + const dialog = container.querySelector('[role="dialog"]')! + const input = container.querySelector('input')! + const scrollBody = input.parentElement! + expect(dialog.getAttribute('aria-label')).toBe('Configure workflow') + expect(dialog.classList.contains('translate-x-0')).toBe(true) + expect(dialog.classList.contains('shadow-overlay')).toBe(true) + expect(dialog.hasAttribute('inert')).toBe(false) + + input.value = 'Edited workflow' + scrollBody.scrollTop = 64 + render(false) + expect(container.querySelector('[role="dialog"]')).toBe(dialog) + expect(container.querySelector('input')).toBe(input) + expect(input.value).toBe('Edited workflow') + expect(input.parentElement).toBe(scrollBody) + expect(scrollBody.scrollTop).toBe(64) + expect(dialog.classList.contains('translate-x-full')).toBe(true) + expect(dialog.classList.contains('shadow-overlay')).toBe(false) + expect(dialog.hasAttribute('inert')).toBe(true) + + render(true) + expect(dialog.classList.contains('translate-x-0')).toBe(true) + expect(dialog.hasAttribute('inert')).toBe(false) + expect(input.value).toBe('Edited workflow') + expect(scrollBody.scrollTop).toBe(64) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-sidebar-layout.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-sidebar-layout.tsx new file mode 100644 index 00000000000..728d6e4c410 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-sidebar-layout.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface TableSidebarShellProps { + open: boolean + 'aria-label': string + children: ReactNode +} + +/** The shared sliding shell for table configuration sidebars. */ +export function TableSidebarShell({ + open, + children, + 'aria-label': ariaLabel, +}: TableSidebarShellProps) { + return ( + + ) +} + +interface TableSidebarScrollBodyProps { + children: ReactNode +} + +/** The scrolling form area shared by column, workflow, and enrichment settings. */ +export function TableSidebarScrollBody({ children }: TableSidebarScrollBodyProps) { + return ( +
+ {children} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index 18b04cfd5b2..672fe326307 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -6,19 +6,18 @@ import { ChipCombobox, ChipInput, type ComboboxOptionGroup, - cn, - DashedDividerLine, + FieldDisclosure, FieldDivider, Label, Loader, OverflowText, Switch, - Tooltip, toast, } from '@sim/emcn' -import { ArrowLeft, ChevronDown, SquareArrowUpRight, X } from '@sim/emcn/icons' +import { ArrowLeft, SquareArrowUpRight, X } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { WorkflowPreviewAction } from '@/components/workflow/workflow-preview-action' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' import type { AddWorkflowGroupBodyInput, @@ -46,6 +45,14 @@ import { FieldError, RequiredLabel, } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' +import { + TableSidebarHeader, + TableSidebarHeaderAction, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-sidebar-header/table-sidebar-header' +import { + TableSidebarScrollBody, + TableSidebarShell, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-sidebar-layout' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { BlockTile } from '@/blocks/block-tile' import { useDeployedWorkflowState } from '@/hooks/queries/deployments' @@ -146,18 +153,11 @@ interface BlockOutputGroup { export function WorkflowSidebar(props: WorkflowSidebarProps) { const open = props.config !== null return ( - + ) } @@ -630,37 +630,27 @@ export function WorkflowSidebarBody({ return (
-
+
{showBackButton && ( - + )}

- -
+ + -
+ {/* Single-output mode renames this column directly. */} {isEditOutputMode && ( <> @@ -717,26 +707,18 @@ export function WorkflowSidebarBody({ />
{!isEnrichment && ( - - - - - Open workflow - + + window.open( + `/workspace/${workspaceId}/w/${selectedWorkflowId}`, + '_blank', + 'noopener,noreferrer' + ) + } + > + + )} ) : ( @@ -837,23 +819,9 @@ export function WorkflowSidebarBody({ )} {selectedWorkflowId && ( <> -
- - - -
+ setShowAdvanced((v) => !v)}> + {showAdvanced ? 'Hide additional fields' : 'Show additional fields'} + {showAdvanced && ( <> )} -
+
, Button: ({ children, className: _className, @@ -104,7 +107,10 @@ vi.mock('@sim/emcn', () => ({ PopoverTrigger: ({ children }: { children: ReactNode }) =>
{children}
, Tooltip: { Root: ({ children }: { children: ReactNode }) =>
{children}
, - Trigger: ({ children }: { children: ReactNode }) =>
{children}
, + Trigger: ({ children }: { children: ReactElement }) => + cloneElement(children as ReactElement<{ onPointerEnter?: () => void }>, { + onPointerEnter: mockTooltipPointerEnter, + }), Content: ({ children }: { children: ReactNode }) =>
{children}
, }, Trash: () => , @@ -199,7 +205,7 @@ vi.mock('@/stores/chat/store', () => ({ })) vi.mock('@/stores/execution', () => ({ - useIsCurrentWorkflowExecuting: () => false, + useIsCurrentWorkflowExecuting: () => executionState.isExecuting, })) vi.mock('@/stores/operation-queue/store', () => ({ @@ -214,7 +220,7 @@ vi.mock('@/stores/terminal', () => ({ vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: (selector: (state: { activeWorkflowId: string }) => unknown) => - selector({ activeWorkflowId: 'workflow-1' }), + selector(registryState), })) vi.mock('@/stores/workflows/subblock/store', () => ({ @@ -234,6 +240,7 @@ vi.mock('@/stores/chat/utils', () => ({ })) import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat' +import { MAX_CHAT_FILES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks' let container: HTMLDivElement let root: Root @@ -280,6 +287,8 @@ describe('floating chat attachment uploads', () => { beforeEach(async () => { vi.useFakeTimers() vi.clearAllMocks() + executionState.isExecuting = false + registryState.activeWorkflowId = 'workflow-1' mockCreateObjectURL.mockReturnValue('blob:diagram-preview') mockReadSSEEvents.mockResolvedValue(undefined) vi.stubGlobal('FileReader', mockFileReader) @@ -308,6 +317,70 @@ describe('floating chat attachment uploads', () => { vi.unstubAllGlobals() }) + it('opens file selection through a named native action', () => { + const action = container.querySelector('button[aria-label="Attach file"]') + const fileInput = container.querySelector('#floating-chat-file-input') + expect(action?.type).toBe('button') + expect(action?.disabled).toBe(false) + expect(action?.querySelector('[data-icon="Paperclip"]')).not.toBeNull() + expect(fileInput).not.toBeNull() + + const openPicker = vi.fn() + if (fileInput) fileInput.click = openPicker + act(() => action?.click()) + expect(openPicker).toHaveBeenCalledTimes(1) + }) + + it.each(['executing', 'no workflow'] as const)('prevents file selection with %s', (condition) => { + executionState.isExecuting = condition === 'executing' + registryState.activeWorkflowId = condition === 'no workflow' ? '' : 'workflow-1' + act(() => root.render()) + + const action = container.querySelector('button[aria-label="Attach file"]') + const fileInput = container.querySelector('#floating-chat-file-input') + expect(action?.disabled).toBe(true) + expect(fileInput?.disabled).toBe(true) + + const openPicker = vi.fn() + if (fileInput) fileInput.click = openPicker + act(() => action?.click()) + expect(openPicker).not.toHaveBeenCalled() + }) + + it('keeps the tooltip hover target available when attachment is disabled', () => { + executionState.isExecuting = true + act(() => root.render()) + + const action = container.querySelector('button[aria-label="Attach file"]') + const trigger = action?.parentElement + expect(action?.disabled).toBe(true) + expect(trigger?.tagName).toBe('SPAN') + expect(trigger?.hasAttribute('disabled')).toBe(false) + + act(() => trigger?.dispatchEvent(new Event('pointerover', { bubbles: true }))) + expect(mockTooltipPointerEnter).toHaveBeenCalledTimes(1) + }) + + it('disables attachment selection at the file limit', () => { + const fileInput = container.querySelector('#floating-chat-file-input') + if (!fileInput) throw new Error('Expected file input') + Object.defineProperty(fileInput, 'files', { + configurable: true, + value: Array.from( + { length: MAX_CHAT_FILES }, + (_, index) => new File(['x'], `file-${index}.txt`) + ), + }) + act(() => fileInput.dispatchEvent(new Event('change', { bubbles: true }))) + + const action = container.querySelector('button[aria-label="Attach file"]') + expect(action?.disabled).toBe(true) + const openPicker = vi.fn() + fileInput.click = openPicker + act(() => action?.click()) + expect(openPicker).not.toHaveBeenCalled() + }) + it('uses uploaded URLs for message previews without base64 conversion', async () => { const file = new File(['diagram'], 'diagram.png', { type: 'image/png' }) mockHandleRunWorkflow.mockResolvedValue({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 2dff953adcd..9e338f9a70c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -2,7 +2,6 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - Badge, Button, ComposerActionButton, cn, @@ -131,7 +130,7 @@ function ChatFilePreview({ file, onRemove }: ChatFilePreviewProps) { ) : (
{file.name}
-
{formatFileSize(file.size)}
+
{formatFileSize(file.size)}
)} @@ -1091,16 +1090,21 @@ export function Chat() {
- document.getElementById('floating-chat-file-input')?.click()} - className={cn( - 'cursor-pointer rounded-md border-0! bg-transparent! p-[0px]', - (!activeWorkflowId || isExecuting || chatFiles.length >= MAX_CHAT_FILES) && - 'cursor-not-allowed opacity-50' - )} - > - - + + + Attach file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index 88478ea513c..5851d2bb03b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -79,7 +79,7 @@ export function ChatMessage({ message }: ChatMessageProps) { )} {formattedContent && !formattedContent.startsWith('Uploaded') && ( -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx index 3d2e7e8b88a..f8cc36b1ef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx @@ -208,7 +208,7 @@ export function CommandList() { {/* Right side: Keyboard Shortcut */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index fc2973f9531..1f2afd7a6f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -493,7 +493,7 @@ console.log(limits);` code={getSyncCommand()} language={LANGUAGE_SYNTAX[language]} wrapText - className='min-h-0! rounded-sm border border-[var(--border-1)]' + className='min-h-0!' />
@@ -531,7 +531,7 @@ console.log(limits);` code={getStreamCommand()} language={LANGUAGE_SYNTAX[language]} wrapText - className='min-h-0! rounded-sm border border-[var(--border-1)]' + className='min-h-0!' />
@@ -575,7 +575,7 @@ console.log(limits);` code={getAsyncCommand()} language={LANGUAGE_SYNTAX[language]} wrapText - className='min-h-0! rounded-sm border border-[var(--border-1)]' + className='min-h-0!' />
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx new file mode 100644 index 00000000000..039844dfa4d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat-field-error.test.tsx @@ -0,0 +1,86 @@ +import type { ComponentProps, PropsWithChildren } from 'react' +import { JSDOM } from 'jsdom' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +const validation = vi.hoisted(() => ({ + current: { isChecking: false, error: 'Use lowercase letters', isValid: false } as { + isChecking: boolean + error: string | null + isValid: boolean + }, +})) + +vi.mock('@sim/emcn', () => ({ + Input: (props: ComponentProps<'input'>) => , + Label: (props: ComponentProps<'label'>) => ( + + ), + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: PropsWithChildren) => <>{children}, + Trigger: ({ children }: PropsWithChildren) => <>{children}, + Content: ({ children }: PropsWithChildren) => <>{children}, + }, +})) +vi.mock('@sim/emcn/icons', () => ({ Check: () => null, TriangleAlert: () => null })) +vi.mock('@sim/logger', () => ({ createLogger: () => ({}) })) +vi.mock('@/components/ui', () => ({ GeneratedPasswordInput: () => null })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({}) })) +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.ai', + getEmailDomain: () => 'sim.ai', +})) +vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => true })) +vi.mock('@/lib/workflows/streaming/output-selector', () => ({ + formatInternalOutputSelector: () => '', +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select', + () => ({ + OutputSelect: () => null, + }) +) +vi.mock('@/hooks/queries/chats', () => ({ + useCreateChat: () => ({}), + useDeleteChat: () => ({}), + useRevealChatPassword: () => ({}), + useUpdateChat: () => ({}), +})) +vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({}) })) +vi.mock('./hooks', () => ({ useIdentifierValidation: () => validation.current })) + +import { IdentifierInput } from './chat' + +function renderIdentifier() { + return new JSDOM(renderToStaticMarkup()) + .window.document +} + +describe('deploy URL field error', () => { + it('announces and associates the URL validation error with its input', () => { + validation.current = { isChecking: false, error: 'Use lowercase letters', isValid: false } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + const alert = document.querySelector('[role="alert"]') + + expect(alert?.textContent).toBe('Use lowercase letters') + expect(alert?.className).toBe('mt-[6.5px] text-[var(--text-error)] text-caption') + expect(input?.getAttribute('aria-invalid')).toBe('true') + expect(input?.getAttribute('aria-describedby')).toBe(alert?.id) + expect(alert?.id).toBeTruthy() + expect(document.querySelector('label')?.htmlFor).toBe(input?.id) + }) + + it('omits the error relationship when the URL is valid', () => { + validation.current = { isChecking: false, error: null, isValid: true } + const document = renderIdentifier() + const input = document.querySelector('#chat-url') + + expect(document.querySelector('[role="alert"]')).toBeNull() + expect(input?.getAttribute('aria-invalid')).toBe('false') + expect(input?.hasAttribute('aria-describedby')).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 7eb3a45fecd..ed8d5ff33b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -1,19 +1,19 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' import { ChipButtonGroup, ChipButtonGroupItem, ChipConfirmModal, ChipEmailsInput, ChipInput, + ChipModalField, cn, Input, Label, Loader, Skeleton, Switch, - Textarea, Tooltip, } from '@sim/emcn' import { Check, TriangleAlert } from '@sim/emcn/icons' @@ -49,6 +49,19 @@ const logger = createLogger('ChatDeploy') const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/ +interface DeployFieldErrorProps { + children: ReactNode + id?: string +} + +function DeployFieldError({ children, id }: DeployFieldErrorProps) { + return ( + + ) +} + interface ChatDeployProps { workflowId: string deploymentInfo: { @@ -335,12 +348,7 @@ export function ChatDeploy({ return ( <> - + {errors.general && (
@@ -358,24 +366,21 @@ export function ChatDeploy({ isEditingExisting={!!existingChat} /> -
- - updateField('title', e.target.value)} - required - disabled={chatSubmitting} - /> - {errors.title && ( -

{errors.title}

+ + {(aria) => ( + updateField('title', e.target.value)} + required + disabled={chatSubmitting} + {...aria} + /> )} -
+ -
+
- {errors.outputBlocks && ( -

- {errors.outputBlocks} -

- )} + {errors.outputBlocks && {errors.outputBlocks}}
-
+
@@ -407,7 +408,7 @@ export function ChatDeploy({ />
-
+
@@ -434,23 +435,17 @@ export function ChatDeploy({ hasExistingPassword={existingPassword} error={errors.password || errors.emails} /> -
- -