Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/rules/emcn-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`.
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
- **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock.
- **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`.
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
- **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array.
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
- **`ChipDatePicker`** — chip-styled date field.
Expand Down
4 changes: 2 additions & 2 deletions .cursor/rules/emcn-components.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`.
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
- **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock.
- **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`.
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
- **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array.
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
- **`ChipDatePicker`** — chip-styled date field.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,8 @@ export default function ResumeExecutionPage({
placeholder='{"example": "value"}'
rows={6}
spellCheck={false}
className='min-h-[180px] font-mono'
monospace
className='min-h-[180px]'
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ function ClientCredentialAccountModalForm({
value={value}
onChange={(event) => setField(field.id, event.target.value)}
placeholder={field.placeholder}
className='min-h-[120px] font-mono'
monospace
className='min-h-[120px]'
// Browser spell-check and autofill ship textarea contents to
// third-party services — an exfiltration route for a pasted
// private key. `ChipModalField type='textarea'` exposes none
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,8 @@ export function McpServerFormModal({
type='button'
variant='ghost'
onClick={() => setShowAdvanced((v) => !v)}
className='gap-1 self-start py-0 text-small'
size='inline'
className='gap-1 self-start'
>
{showAdvanced ? (
<ChevronDown className='size-[14px]' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,16 @@ export function NoOrganizationView({

<div>
<Label htmlFor='orgSlug'>Team URL</Label>
<div className='mt-1 flex items-center'>
<div className='rounded-l-[6px] border border-[var(--border-1)] border-r-0 bg-[var(--surface-4)] px-3 py-1.5 text-[var(--text-muted)] text-small'>
sim.ai/team/
</div>
<ChipInput
id='orgSlug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
placeholder='my-team'
className='rounded-l-none'
/>
</div>
<ChipInput
id='orgSlug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
placeholder='my-team'
className='mt-1'
startAdornment={
<span className='shrink-0 text-[var(--text-muted)] text-small'>sim.ai/team/</span>
}
/>
</div>

<div className='flex flex-col gap-2'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function LockSettingsModal({
<ChipModalField
key={field.key}
type='custom'
className='flex-row items-center justify-between'
orientation='horizontal'
title={
<span className='inline-flex items-center gap-1.5'>
{field.label}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'>) => <input {...props} />,
Label: (props: ComponentProps<'label'>) => (
<label htmlFor={props.htmlFor} className={props.className}>
{props.children}
</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(<IdentifierInput value='bad path' onChange={vi.fn()} />))
.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<HTMLInputElement>('#chat-url')
const alert = document.querySelector<HTMLElement>('[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<HTMLInputElement>('#chat-url')

expect(document.querySelector('[role="alert"]')).toBeNull()
expect(input?.getAttribute('aria-invalid')).toBe('false')
expect(input?.hasAttribute('aria-describedby')).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
import {
ChipButtonGroup,
ChipButtonGroupItem,
Expand Down Expand Up @@ -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 (
<p id={id} role='alert' className='mt-[6.5px] text-[var(--text-error)] text-caption'>
{children}
</p>
)
}

interface ChatDeployProps {
workflowId: string
deploymentInfo: {
Expand Down Expand Up @@ -382,11 +395,7 @@ export function ChatDeploy({
className='w-full'
disablePortal
/>
{errors.outputBlocks && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
{errors.outputBlocks}
</p>
)}
{errors.outputBlocks && <DeployFieldError>{errors.outputBlocks}</DeployFieldError>}
</div>

<div className='flex items-center justify-between gap-3 px-2'>
Expand Down Expand Up @@ -539,14 +548,15 @@ const getDomainPrefix = (() => {
return () => prefix
})()

function IdentifierInput({
export function IdentifierInput({
value,
onChange,
originalIdentifier,
disabled = false,
onValidationChange,
isEditingExisting = false,
}: IdentifierInputProps) {
const errorId = useId()
const { isChecking, error, isValid } = useIdentifierValidation(
value,
originalIdentifier,
Expand Down Expand Up @@ -590,6 +600,8 @@ function IdentifierInput({
onChange={(e) => handleChange(e.target.value)}
required
disabled={disabled}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
className={cn(
'rounded-none border-0 bg-transparent pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
(isChecking || (isValid && value)) && 'pr-8'
Expand Down Expand Up @@ -617,7 +629,7 @@ function IdentifierInput({
)}
</div>
</div>
{error && <p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{error}</p>}
{error && <DeployFieldError id={errorId}>{error}</DeployFieldError>}
<p className='mt-[6.5px] truncate text-[var(--text-secondary)] text-xs'>
{isEditingExisting && value ? (
<>
Expand Down Expand Up @@ -745,9 +757,7 @@ function AuthSelector({
}
/>
{canRevealPassword && revealPasswordMutation.isError && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
Failed to load the current password
</p>
<DeployFieldError>Failed to load the current password</DeployFieldError>
)}
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
{getPasswordHelperText(hasExistingPassword)}
Expand All @@ -772,7 +782,7 @@ function AuthSelector({
</div>
)}

{error && <p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{error}</p>}
{error && <DeployFieldError>{error}</DeployFieldError>}
</div>
)
}
Loading
Loading