From 4d50bd90c7d426c356fe7b07d0e738aca4acdc09 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:53:17 -0700 Subject: [PATCH 1/2] fix(react): use stable keys for dynamic lists --- apps/docs/app/api/og/route.tsx | 18 ++++++-- .../workflow-preview/format-references.tsx | 9 ++-- .../chat/components/input/input.tsx | 23 +++++++--- .../components/hero-visual/stage-home.tsx | 6 +-- .../components/hero-visual/workflow-data.ts | 29 +++++++++---- .../preview-block-node.tsx | 19 +++++---- .../components/legal-block/legal-block.tsx | 13 ++++-- .../legal-block-group/legal-block-group.tsx | 17 ++++++-- .../build-methods-graphic.tsx | 28 ++++++++++--- apps/sim/app/playground/page.tsx | 20 +++++---- .../message-actions/message-actions.tsx | 8 +++- .../resource-header/resource-header.tsx | 23 ++++++++-- .../components/file-viewer/data-table.tsx | 27 ++++++++---- .../components/agent-group/agent-group.tsx | 15 +++++-- .../components/chat-content/chat-content.tsx | 21 ++++++---- .../components/interaction-card.tsx | 27 ++++++++---- .../components/special-tags/special-tags.tsx | 14 ++++--- .../message-content/message-content.tsx | 15 +++++-- .../components/drop-overlay/drop-overlay.tsx | 22 +++++----- .../prompt-editor/prompt-editor.tsx | 4 +- .../user-message-content.tsx | 6 +-- .../client-credential-account-modal.tsx | 1 + .../connect-service-account-modal.tsx | 2 + .../token-service-account-modal.tsx | 1 + .../components/chunk-editor/chunk-editor.tsx | 26 +++++++----- .../search-highlight/search-highlight.tsx | 9 ++-- .../components/status-bar/status-bar.tsx | 2 +- .../file-download/file-download.tsx | 4 +- .../mcp-server-form-modal.tsx | 13 ++++-- .../settings/components/mcp/mcp.tsx | 15 +++++-- .../w/[workflowId]/components/chat/chat.tsx | 6 +-- .../components/chat-message/chat-message.tsx | 21 ++++++---- .../chat/hooks/use-chat-file-upload.test.tsx | 30 ++++++++++++- .../chat/hooks/use-chat-file-upload.ts | 18 +++++--- .../components/version-description-modal.tsx | 1 + .../messages-input/messages-input.tsx | 16 ++++++- .../custom-tool-modal/custom-tool-modal.tsx | 4 +- .../pii/custom-patterns-editor.test.tsx | 17 ++++++++ .../components/pii/custom-patterns-editor.tsx | 22 +++++++++- .../components/data-retention-settings.tsx | 21 +++++++--- .../components/fork-sync/fork-sync-view.tsx | 42 ++++++++++++++----- apps/sim/lib/content/faq.tsx | 39 ++++++++++++----- .../chip-emails-input/chip-emails-input.tsx | 13 ++++-- .../src/components/chip-modal/chip-modal.tsx | 13 ++++-- .../components/chip-select/chip-select.tsx | 12 +++++- packages/emcn/src/components/index.ts | 1 + .../src/components/tag-input/tag-input.tsx | 7 +++- .../workflow-block/canvas-sentence-view.tsx | 12 +++++- 48 files changed, 536 insertions(+), 196 deletions(-) diff --git a/apps/docs/app/api/og/route.tsx b/apps/docs/app/api/og/route.tsx index ee99eec89e9..9bc5270ae2a 100644 --- a/apps/docs/app/api/og/route.tsx +++ b/apps/docs/app/api/og/route.tsx @@ -102,7 +102,12 @@ function splitOversizedWord(word: string, maxWidthEm: number): string[] { * so it sidesteps the bug instead of fighting Satori's own line-wrapping * (which is also disabled here — lines are pre-split, not auto-wrapped). */ -function wrapTitleLines(title: string, fontSize: number): string[] { +interface TitleLine { + text: string + sourceOffset: number +} + +function wrapTitleLines(title: string, fontSize: number): TitleLine[] { const maxWidthEm = TITLE_BOX_WIDTH / fontSize const words = title.split(' ') const lines: string[] = [] @@ -130,7 +135,12 @@ function wrapTitleLines(title: string, fontSize: number): string[] { } if (current) lines.push(current) - return lines.map((line) => line.replace(/ /g, ' ')) + let lineOffset = 0 + return lines.map((line) => { + const sourceOffset = lineOffset + lineOffset += line.length + 1 + return { text: line.replace(/ /g, ' '), sourceOffset } + }) } /** @@ -211,8 +221,8 @@ export async function GET(request: NextRequest) {
{trimmed.slice(4)} @@ -312,7 +317,7 @@ function NoteMarkdown({ content }: { content: string }) { return (
{block.content}
case 'subheading': return
- {renderCodeLine(line, typedCodeChars - CODE_LINE_STARTS[index])}
+ {renderCodeLine(line.segments, typedCodeChars - CODE_LINE_STARTS[index])}
{codeTypingActive && index === lastStartedLine && (
)}
diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx
index ddee87cff6f..07b88619dfa 100644
--- a/apps/sim/app/playground/page.tsx
+++ b/apps/sim/app/playground/page.tsx
@@ -84,6 +84,7 @@ import {
ZoomOut,
} from '@sim/emcn'
import { ArrowLeft, Folder, Moon, Sun } from '@sim/emcn/icons'
+import { generateShortId } from '@sim/utils/id'
import { notFound, useRouter } from 'next/navigation'
import { env, isTruthy } from '@/lib/core/config/env'
@@ -148,9 +149,14 @@ export default function PlaygroundPage() {
const [dateValue, setDateValue] = useState('')
const [dateRangeStart, setDateRangeStart] = useState('')
const [dateRangeEnd, setDateRangeEnd] = useState('')
- const [tagItems, setTagItems] = useState([
- { value: 'user@example.com', isValid: true },
- { value: 'invalid-email', isValid: false, error: 'Invalid email format' },
+ const [tagItems, setTagItems] = useState(() => [
+ { id: generateShortId(), value: 'user@example.com', isValid: true },
+ {
+ id: generateShortId(),
+ value: 'invalid-email',
+ isValid: false,
+ error: 'Invalid email format',
+ },
])
const toggleDarkMode = () => {
@@ -439,7 +445,7 @@ export default function PlaygroundPage() {
items={tagItems}
onAdd={(value) => {
const isValid = value.includes('@') && value.includes('.')
- setTagItems((prev) => [...prev, { value, isValid }])
+ setTagItems((prev) => [...prev, { id: generateShortId(), value, isValid }])
return isValid
}}
onRemove={(_, index) => {
@@ -454,8 +460,8 @@ export default function PlaygroundPage() {
true}
onRemove={() => {}}
@@ -468,7 +474,7 @@ export default function PlaygroundPage() {
false}
onRemove={() => {}}
placeholder='Disabled input'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index 9e0085ae9cd..651db081c23 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -268,7 +268,13 @@ export const MessageActions = memo(function MessageActions({
onCancel={() => handleModalClose(false)}
secondaryActions={
pendingFeedback === 'down' && requestId
- ? [{ label: copiedRequestId ? 'Copied' : 'Copy ID', onClick: copyRequestId }]
+ ? [
+ {
+ id: 'copy-request-id',
+ label: copiedRequestId ? 'Copied' : 'Copy ID',
+ onClick: copyRequestId,
+ },
+ ]
: undefined
}
primaryAction={{
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
index 4231b7ad534..a202fafb903 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
@@ -80,6 +80,19 @@ export interface BreadcrumbItem {
terminal?: boolean
}
+function keyedBreadcrumbs(breadcrumbs: BreadcrumbItem[]) {
+ const occurrences = new Map()
+ return breadcrumbs.map((crumb, index) => {
+ const signature =
+ crumb.folderId !== undefined
+ ? `folder:${crumb.folderId ?? 'root'}`
+ : `segment:${crumb.label}:${crumb.terminal === true ? 'terminal' : 'resource'}`
+ const occurrence = occurrences.get(signature) ?? 0
+ occurrences.set(signature, occurrence + 1)
+ return { crumb, index, key: `${signature}:${occurrence}` }
+ })
+}
+
/**
* The single, strict contract for a top-right header action. Every action renders
* as a {@link Chip} — consumers describe intent through these fields and nothing
@@ -160,6 +173,7 @@ export const ResourceHeader = memo(function ResourceHeader({
: hasBreadcrumbs && breadcrumbs.length > 2
? breadcrumbs.length - 1
: -1
+ const breadcrumbEntries = breadcrumbs ? keyedBreadcrumbs(breadcrumbs) : []
return (
{hasBreadcrumbs ? (
- breadcrumbs.map((crumb, i) => {
+ breadcrumbEntries.map(({ crumb, index: i, key }) => {
const segmentClassName = getBreadcrumbSegmentClassName(
i,
breadcrumbs.length,
@@ -204,7 +218,7 @@ export const ResourceHeader = memo(function ResourceHeader({
: undefined
return (
-
+
{i > 0 && (
/
@@ -465,6 +479,7 @@ function BreadcrumbLocationPopover({
const [open, setOpen] = useState(false)
const closeTimeoutRef = useRef | null>(null)
const rootBreadcrumb = breadcrumbs[0]
+ const breadcrumbEntries = keyedBreadcrumbs(breadcrumbs)
const cancelScheduledClose = () => {
if (closeTimeoutRef.current) {
@@ -569,9 +584,9 @@ function BreadcrumbLocationPopover({
- {breadcrumbs.map((crumb, index) => (
+ {breadcrumbEntries.map(({ crumb, index, key }) => (
navigateAndClose(crumb.onClick) : undefined}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx
index a8471c89d3d..6ef3e02e02f 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx
@@ -103,20 +103,27 @@ const DataTableBase = forwardRef(function DataT
const isEditing = (row: number, col: number) =>
editingCell?.row === row && editingCell?.col === col
+ const columnOccurrences = new Map()
+ const columns = headers.map((header, columnIndex) => {
+ const occurrence = columnOccurrences.get(header) ?? 0
+ columnOccurrences.set(header, occurrence + 1)
+ return { id: JSON.stringify([header, occurrence]), header, columnIndex }
+ })
+
return (
- {headers.map((header, i) => (
+ {columns.map(({ id, header, columnIndex }) => (
editConfig && startEdit(-1, i, String(header ?? ''))}
+ onClick={() => editConfig && startEdit(-1, columnIndex, String(header ?? ''))}
>
- {isEditing(-1, i) ? (
+ {isEditing(-1, columnIndex) ? (
(function DataT
{rows.map((row, ri) => (
- {headers.map((_, ci) => (
+ {columns.map(({ id, columnIndex }) => (
editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
+ onClick={() =>
+ editConfig && startEdit(ri, columnIndex, String(row[columnIndex] ?? ''))
+ }
>
- {isEditing(ri, ci) ? (
+ {isEditing(ri, columnIndex) ? (
(function DataT
className='w-full min-w-[60px] bg-transparent outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
/>
) : (
- String(row[ci] ?? '')
+ String(row[columnIndex] ?? '')
)}
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
index 103e6ba6e4f..537a4bda9ef 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
@@ -154,6 +154,8 @@ export function AgentGroup({
setManualExpanded(!expanded)
}
+ let previousItemKey = `group:${agentName}:${agentLabel}`
+
return (
{hasItems ? (
@@ -195,10 +197,17 @@ export function AgentGroup({
{items.map((item, idx) => {
+ const itemKey =
+ item.type === 'tool'
+ ? `tool:${item.data.id}`
+ : item.type === 'agent_group'
+ ? `agent:${item.group.id}`
+ : `text-after:${previousItemKey}`
+ previousItemKey = itemKey
if (item.type === 'tool') {
return (
+
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index c143c72bb4a..b178bfd3ab6 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -569,15 +569,17 @@ function ChatContentInner({
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' }
>
type RenderGroup =
- | { kind: 'inline'; markdown: string }
- | { kind: 'block'; segment: BlockSegment; index: number }
+ | { id: string; kind: 'inline'; markdown: string }
+ | { id: string; kind: 'block'; segment: BlockSegment; index: number }
const groups: RenderGroup[] = []
let pendingMarkdown = ''
+ let inlineAnchor = 'start'
+ const specialOccurrences = new Map()
const flushMarkdown = () => {
if (pendingMarkdown.trim()) {
- groups.push({ kind: 'inline', markdown: pendingMarkdown })
+ groups.push({ id: `inline-after:${inlineAnchor}`, kind: 'inline', markdown: pendingMarkdown })
}
pendingMarkdown = ''
}
@@ -604,7 +606,12 @@ function ChatContentInner({
pendingMarkdown += s.content
} else {
flushMarkdown()
- groups.push({ kind: 'block', segment: s, index: i })
+ const signature = `${s.type}:${JSON.stringify(s.data)}`
+ const occurrence = specialOccurrences.get(signature) ?? 0
+ specialOccurrences.set(signature, occurrence + 1)
+ const id = `special:${signature}:${occurrence}`
+ groups.push({ id, kind: 'block', segment: s, index: i })
+ inlineAnchor = id
}
}
flushMarkdown()
@@ -622,11 +629,11 @@ function ChatContentInner({
*/
return (
- {groups.map((group, i) => {
+ {groups.map((group) => {
if (group.kind === 'inline') {
return (
:first-child]:mt-0 [&>:last-child]:mb-0')}
>
()
return (
- {items.map((item, index) => (
-
- {item.label}
-
- {item.values.map((value, valueIndex) => (
- {value}
- ))}
+ {items.map((item) => {
+ const signature = `${item.label}\u0000${item.values.join('\u0000')}`
+ const occurrence = itemOccurrences.get(signature) ?? 0
+ itemOccurrences.set(signature, occurrence + 1)
+ const valueOccurrences = new Map()
+ return (
+
+ {item.label}
+
+ {item.values.map((value) => {
+ const valueOccurrence = valueOccurrences.get(value) ?? 0
+ valueOccurrences.set(value, valueOccurrence + 1)
+ return {value}
+ })}
+
-
- ))}
+ )
+ })}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index c79acb6a2e0..77550e43c6a 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -2695,14 +2695,16 @@ export function CredentialDisplay({
)
}
+ const itemOccurrences = new Map()
+
return (
1 && 'space-y-3')}>
- {data.map((item, index) => (
-
- ))}
+ {data.map((item) => {
+ const signature = JSON.stringify(item)
+ const occurrence = itemOccurrences.get(signature) ?? 0
+ itemOccurrences.set(signature, occurrence + 1)
+ return
+ })}
)
}
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 4b230fb4fd3..ee38e6159a5 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
@@ -59,6 +59,7 @@ interface AgentGroupSegment {
interface OptionsSegment {
type: 'options'
+ id: string
items: OptionItem[]
}
@@ -429,7 +430,11 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
if (block.type === 'options') {
if (!block.options?.length) continue
- segments.push({ type: 'options', items: block.options })
+ segments.push({
+ type: 'options',
+ id: `options-${block.timestamp ?? 'untimed'}-${block.options.map((item) => item.id).join(':')}`,
+ items: block.options,
+ })
continue
}
@@ -662,7 +667,11 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
if (block.type === 'options') {
if (!block.options?.length) continue
flushLanes()
- segments.push({ type: 'options', items: block.options })
+ segments.push({
+ type: 'options',
+ id: `options-${block.timestamp ?? 'untimed'}-${block.options.map((item) => item.id).join(':')}`,
+ items: block.options,
+ })
continue
}
@@ -966,7 +975,7 @@ function MessageContentInner({
case 'options':
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx
index 03993db036e..2a5bb6eea25 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx
@@ -14,15 +14,15 @@ import {
} from '@/components/icons/document-icons'
const DROP_OVERLAY_ICONS = [
- PdfIcon,
- DocxIcon,
- XlsxIcon,
- CsvIcon,
- TxtIcon,
- MarkdownIcon,
- JsonIcon,
- AudioIcon,
- VideoIcon,
+ { id: 'pdf', Icon: PdfIcon },
+ { id: 'docx', Icon: DocxIcon },
+ { id: 'xlsx', Icon: XlsxIcon },
+ { id: 'csv', Icon: CsvIcon },
+ { id: 'txt', Icon: TxtIcon },
+ { id: 'markdown', Icon: MarkdownIcon },
+ { id: 'json', Icon: JsonIcon },
+ { id: 'audio', Icon: AudioIcon },
+ { id: 'video', Icon: VideoIcon },
] as const
export const DropOverlay = memo(function DropOverlay() {
@@ -31,8 +31,8 @@ export const DropOverlay = memo(function DropOverlay() {
Drop files
- {DROP_OVERLAY_ICONS.map((Icon, i) => (
-
+ {DROP_OVERLAY_ICONS.map(({ id, Icon }) => (
+
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx
index af7013cb84b..65e34fb46ba 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx
@@ -182,7 +182,7 @@ export function PromptEditor({
if (range.start > lastIndex) {
const before = value.slice(lastIndex, range.start)
- elements.push({before})
+ elements.push({before})
}
const mentionLabel = stripMentionTrigger(range.token)
@@ -196,7 +196,7 @@ export function PromptEditor({
) : null
elements.push(
-
+
{/* Invisible trigger glyph keeps the overlay's advance identical to
the transparent textarea; the icon centers over its slot. */}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
index f7ed8d201b2..681d2a1ca0d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
@@ -142,18 +142,18 @@ export function UserMessageContent({
if (range.start > lastIndex) {
const before = content.slice(lastIndex, range.start)
- elements.push({before})
+ elements.push({before})
}
if (plainMentions) {
elements.push(
-
+
{content.slice(range.start, range.end)}
)
} else {
elements.push(
-
+
)
}
lastIndex = range.end
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
index 4dbbc05545d..00acce4ffa3 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
@@ -348,6 +348,7 @@ export function ClientCredentialAccountModal({
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
+ id: 'setup-guide',
label: 'Setup guide',
onClick: () => openDocs(descriptor.docsUrl),
},
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
index d8f21c6adbc..79c78b69977 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
@@ -416,6 +416,7 @@ function GoogleServiceAccountModal({
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
+ id: 'setup-guide',
label: 'Setup guide',
onClick: () => openDocs(GOOGLE_SERVICE_ACCOUNT_DOCS_URL),
},
@@ -579,6 +580,7 @@ function AtlassianServiceAccountModal({
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
+ id: 'setup-guide',
label: 'Setup guide',
onClick: () => openDocs(ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL),
},
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx
index 25967bb9917..27179e845f4 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx
@@ -210,6 +210,7 @@ export function TokenServiceAccountModal({
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
+ id: 'setup-guide',
label: 'Setup guide',
onClick: () => openDocs(descriptor.docsUrl),
},
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx
index fd6e1f23667..773a8a4b45f 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx
@@ -234,6 +234,8 @@ export function ChunkEditor({
return getTokenStrings(editedContent)
}, [editedContent, tokenizerOn])
+ let tokenOffset = 0
+
const tokenCount = useMemo(() => {
if (!editedContent) return 0
if (tokenizerOn) return tokenStrings.length
@@ -257,16 +259,20 @@ export function ChunkEditor({
>
{tokenizerOn ? (
- {tokenStrings.map((token, index) => (
- setHoveredTokenIndex(index)}
- onMouseLeave={() => setHoveredTokenIndex(null)}
- >
- {token}
-
- ))}
+ {tokenStrings.map((token, index) => {
+ const sourceOffset = tokenOffset
+ tokenOffset += token.length
+ return (
+ setHoveredTokenIndex(index)}
+ onMouseLeave={() => setHoveredTokenIndex(null)}
+ >
+ {token}
+
+ )
+ })}
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx
index 4d93396f252..81e8cc66bc0 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx
@@ -81,7 +81,7 @@ function StatusBarInner({
return (
Files ({files.length})
- {files.map((file, index) => (
+ {files.map((file) => (
{(formData.headers || []).map((header, index) => (
({ key, value }))
+ const entries: { id: string; key: string; value: string }[] = server.headers
+ ? Object.entries(server.headers).map(([key, value]) => ({
+ id: generateShortId(),
+ key,
+ value,
+ }))
: []
- if (entries.length === 0) entries.push({ key: '', value: '' })
+ if (entries.length === 0) entries.push({ id: generateShortId(), key: '', value: '' })
const last = entries[entries.length - 1]
- if (last.key !== '' || last.value !== '') entries.push({ key: '', value: '' })
+ if (last.key !== '' || last.value !== '') {
+ entries.push({ id: generateShortId(), key: '', value: '' })
+ }
return {
name: server.name || '',
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 51a52dae612..cf0a128e771 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
@@ -1034,9 +1034,9 @@ export function Chat() {
File upload error
- {uploadErrors.map((err, idx) => (
-
- {err}
+ {uploadErrors.map((error) => (
+
+ {error.message}
))}
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 f0389e64ad4..7fd75a99008 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
@@ -27,23 +27,30 @@ const WordWrap = ({ text }: { text: string }) => {
if (!text) return null
const parts = text.split(/(\s+)/g)
+ let sourceOffset = 0
return (
<>
- {parts.map((part, index) => {
+ {parts.map((part) => {
+ const partOffset = sourceOffset
+ sourceOffset += part.length
+ if (!part) return null
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
- return {part}
+ return {part}
}
- const chunks = []
+ const chunks: Array<{ sourceOffset: number; text: string }> = []
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
- chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
+ chunks.push({
+ sourceOffset: partOffset + i,
+ text: part.substring(i, i + MAX_WORD_LENGTH),
+ })
}
return (
-
- {chunks.map((chunk, chunkIndex) => (
- {chunk}
+
+ {chunks.map((chunk) => (
+ {chunk.text}
))}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx
index fa04bf6a44b..dd14a279fb4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx
@@ -61,7 +61,7 @@ describe('useChatFileUpload execution errors', () => {
)
})
- expect(result().uploadErrors).toEqual([
+ expect(result().uploadErrors.map((error) => error.message)).toEqual([
'Failed to upload report.pdf: Workspace file storage limit exceeded',
])
expect(result().chatFiles).toHaveLength(1)
@@ -98,7 +98,33 @@ describe('useChatFileUpload execution errors', () => {
})
expect(result().chatFiles).toHaveLength(0)
- expect(result().uploadErrors).toEqual(['oversized.pdf is too large (max 10MB)'])
+ expect(result().uploadErrors.map((error) => error.message)).toEqual([
+ 'oversized.pdf is too large (max 10MB)',
+ ])
+
+ unmount()
+ })
+
+ it('gives duplicate error messages distinct stable identities', () => {
+ const { result, unmount } = renderChatFileUploadHook()
+ const files = [
+ new File(['first'], 'oversized.pdf', { type: 'application/pdf' }),
+ new File(['second'], 'oversized.pdf', { type: 'application/pdf' }),
+ ]
+ for (const file of files) {
+ Object.defineProperty(file, 'size', { value: MAX_CHAT_FILE_SIZE_BYTES + 1 })
+ }
+
+ act(() => {
+ result().addFiles(files)
+ vi.runAllTimers()
+ })
+
+ expect(result().uploadErrors.map((error) => error.message)).toEqual([
+ 'oversized.pdf is too large (max 10MB)',
+ 'oversized.pdf is too large (max 10MB)',
+ ])
+ expect(new Set(result().uploadErrors.map((error) => error.id)).size).toBe(2)
unmount()
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts
index e76bf939dc3..befa85fdde1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts
@@ -9,6 +9,11 @@ export interface ChatFile {
file: File
}
+export interface ChatUploadError {
+ id: string
+ message: string
+}
+
export const MAX_CHAT_FILES = 15
export const MAX_CHAT_FILE_SIZE_BYTES = 10 * 1024 * 1024
@@ -18,7 +23,7 @@ export const MAX_CHAT_FILE_SIZE_BYTES = 10 * 1024 * 1024
*/
export function useChatFileUpload() {
const [chatFiles, setChatFiles] = useState([])
- const [uploadErrors, setUploadErrors] = useState([])
+ const [uploadErrors, setUploadErrors] = useState([])
const [dragCounter, setDragCounter] = useState(0)
const isDragOver = dragCounter > 0
@@ -31,13 +36,16 @@ export function useChatFileUpload() {
setChatFiles((currentFiles) => {
const remainingSlots = Math.max(0, MAX_CHAT_FILES - currentFiles.length)
const candidateFiles = files.slice(0, remainingSlots)
- const errors: string[] = []
+ const errors: ChatUploadError[] = []
const validNewFiles: ChatFile[] = []
for (const file of candidateFiles) {
// Check file size
if (file.size > MAX_CHAT_FILE_SIZE_BYTES) {
- errors.push(`${file.name} is too large (max 10MB)`)
+ errors.push({
+ id: generateId(),
+ message: `${file.name} is too large (max 10MB)`,
+ })
continue
}
@@ -49,7 +57,7 @@ export function useChatFileUpload() {
(newFile) => newFile.name === file.name && newFile.size === file.size
)
if (isDuplicateInCurrent || isDuplicateInNew) {
- errors.push(`${file.name} already added`)
+ errors.push({ id: generateId(), message: `${file.name} already added` })
continue
}
@@ -88,7 +96,7 @@ export function useChatFileUpload() {
* Surface an execution-time upload failure without removing the selected files.
*/
const reportUploadError = useCallback((message: string) => {
- setUploadErrors([message])
+ setUploadErrors([{ id: generateId(), message }])
}, [])
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx
index 18a2f6998bb..c3fd438678c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx
@@ -164,6 +164,7 @@ export function VersionDescriptionModal({
cancelDisabled={updateMutation.isPending || isGenerating}
secondaryActions={[
{
+ id: 'generate',
label: isGenerating ? 'Generating...' : 'Generate',
onClick: handleGenerateDescription,
disabled: isGenerating || updateMutation.isPending,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx
index 79842e8cb5f..6962ba40019 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx
@@ -542,11 +542,23 @@ export function MessagesInput({
}
}, [currentMessages.length])
+ const fallbackOccurrences = new Map()
+ const keyedMessages = currentMessages.map((message, index) => {
+ const signature = `${message.role}\u0000${message.content}`
+ const occurrence = fallbackOccurrences.get(signature) ?? 0
+ fallbackOccurrences.set(signature, occurrence + 1)
+ return {
+ id: messageIdsRef.current[index] ?? `message:${signature}:${occurrence}`,
+ index,
+ message,
+ }
+ })
+
return (
- {currentMessages.map((message, index) => (
+ {keyedMessages.map(({ id, index, message }) => (
setShowDeleteConfirm(true),
variant: 'destructive',
@@ -412,11 +413,12 @@ export function CustomToolModal({
secondaryActions={[
isEditing
? {
+ id: 'delete',
label: 'Delete',
onClick: () => setShowDeleteConfirm(true),
variant: 'destructive',
}
- : { label: 'Back', onClick: () => setActiveSection('schema') },
+ : { id: 'back', label: 'Back', onClick: () => setActiveSection('schema') },
]}
primaryAction={{
label: isEditing ? 'Update Tool' : 'Save Tool',
diff --git a/apps/sim/components/pii/custom-patterns-editor.test.tsx b/apps/sim/components/pii/custom-patterns-editor.test.tsx
index 2336c644d32..cbe2a9c282a 100644
--- a/apps/sim/components/pii/custom-patterns-editor.test.tsx
+++ b/apps/sim/components/pii/custom-patterns-editor.test.tsx
@@ -81,4 +81,21 @@ describe('CustomPatternsEditor', () => {
act(() => remove.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(onChange).toHaveBeenCalledWith([{ name: 'X', regex: 'b+', replacement: '' }])
})
+
+ it('preserves an existing row while rows are appended and truncated', () => {
+ const onChange = vi.fn()
+ const firstPattern = row('a+')
+ renderEditor([firstPattern], onChange)
+
+ const firstRegexInput = container.querySelector('input[value="a+"]') as HTMLInputElement
+ firstRegexInput.focus()
+
+ renderEditor([firstPattern, row('b+')], onChange)
+ expect(container.querySelector('input[value="a+"]')).toBe(firstRegexInput)
+ expect(document.activeElement).toBe(firstRegexInput)
+
+ renderEditor([firstPattern], onChange)
+ expect(container.querySelector('input[value="a+"]')).toBe(firstRegexInput)
+ expect(document.activeElement).toBe(firstRegexInput)
+ })
})
diff --git a/apps/sim/components/pii/custom-patterns-editor.tsx b/apps/sim/components/pii/custom-patterns-editor.tsx
index f441e048cd1..1cdbbfdbb68 100644
--- a/apps/sim/components/pii/custom-patterns-editor.tsx
+++ b/apps/sim/components/pii/custom-patterns-editor.tsx
@@ -1,7 +1,9 @@
'use client'
+import { useState } from 'react'
import { Chip, ChipInput } from '@sim/emcn'
import { Plus, Trash } from '@sim/emcn/icons'
+import { generateShortId } from '@sim/utils/id'
import type { CustomPiiPattern } from '@/lib/guardrails/pii-entities'
import { validateRegexPattern } from '@/lib/guardrails/validate_regex'
@@ -24,26 +26,42 @@ interface CustomPatternsEditorProps {
* was removed rather than kept.
*/
export function CustomPatternsEditor({ patterns, onChange }: CustomPatternsEditorProps) {
+ const [patternIds, setPatternIds] = useState(() => patterns.map(() => generateShortId()))
+
function updateRow(index: number, patch: Partial) {
onChange(patterns.map((pattern, i) => (i === index ? { ...pattern, ...patch } : pattern)))
}
function removeRow(index: number) {
+ setPatternIds((current) => current.filter((_, i) => i !== index))
onChange(patterns.filter((_, i) => i !== index))
}
function addRow() {
if (patterns.length >= MAX_PATTERNS) return
+ setPatternIds((current) => [...current, generateShortId()])
onChange([...patterns, { name: '', regex: '', replacement: '' }])
}
+ const fallbackOccurrences = new Map()
+ const rows = patterns.map((pattern, index) => {
+ const signature = `${pattern.name}\u0000${pattern.regex}\u0000${pattern.replacement}`
+ const occurrence = fallbackOccurrences.get(signature) ?? 0
+ fallbackOccurrences.set(signature, occurrence + 1)
+ return {
+ id: patternIds[index] ?? `pattern:${signature}:${occurrence}`,
+ index,
+ pattern,
+ }
+ })
+
return (
- {patterns.map((pattern, index) => {
+ {rows.map(({ id, index, pattern }) => {
const validation = pattern.regex.length > 0 ? validateRegexPattern(pattern.regex) : null
const error = validation && !validation.valid ? validation.error : undefined
return (
-
+
update({ customPatterns })}
/>
@@ -441,6 +444,7 @@ function PiiStagePanel({ stageKey, description, value, onChange }: PiiStagePanel
}
interface PolicyDetailProps {
+ editorKey: string
draft: PolicyDraft
isNew: boolean
changed: boolean
@@ -457,6 +461,7 @@ interface PolicyDetailProps {
}
function PolicyDetail({
+ editorKey,
draft,
isNew,
changed,
@@ -641,6 +646,7 @@ function PolicyDetail({
/>
)}
{editing ? (
(items: T[], getSignature: (item: T) => string) {
+ const occurrences = new Map()
+ return items.map((item, index) => {
+ const signature = getSignature(item)
+ const occurrence = occurrences.get(signature) ?? 0
+ occurrences.set(signature, occurrence + 1)
+ return { item, index, key: `${signature}:${occurrence}` }
+ })
+}
+
+function clearedRefSignature(ref: ForkClearedRef): string {
+ return `${ref.cause}:${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}`
+}
+
/**
* Sentinel option value for the "New copy" entry - the displayed resolution while a copyable
* is copy-selected, and the way back to the copy flow after mapping. Handled via onSelect,
@@ -763,6 +778,14 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
})),
]
+ const workflowChanges = withOccurrenceKeys(
+ controller.workflowChanges,
+ (change) => `${change.action}:${change.currentName}:${change.otherName}`
+ )
+ const keyedExcludedRows = withOccurrenceKeys(excludedRows, (row) => `${row.name}:${row.tooltip}`)
+ const blockingRefs = withOccurrenceKeys(controller.blockingRefs, clearedRefSignature)
+ const dependentClears = withOccurrenceKeys(controller.dependentClears, clearedRefSignature)
+
return (
@@ -803,13 +826,10 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{controller.workflowChanges.length + excludedRows.length > 0 ? (
- {controller.workflowChanges.map((change, index) => {
+ {workflowChanges.map(({ item: change, key }) => {
const renamed = change.currentName !== change.otherName
return (
-
+
{change.currentName}
@@ -824,8 +844,8 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
)
})}
- {excludedRows.map(({ name, tooltip }, index) => (
-
+ {keyedExcludedRows.map(({ item: { name, tooltip }, key }) => (
+
@@ -950,12 +970,12 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
}
>
- {controller.blockingRefs.map((ref, index) => {
+ {blockingRefs.map(({ item: ref, index, key }) => {
const dropKey = `${ref.kind}:${ref.sourceId}`
const uses = controller.blockingUsesByResource.get(dropKey) ?? 1
return (
@@ -994,12 +1014,12 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{controller.dependentClears.length > 0 ? (
- {controller.dependentClears.map((ref, index) => {
+ {dependentClears.map(({ item: ref, key }) => {
const droppedKey = `${ref.kind}:${ref.sourceId}`
const dropped = controller.droppedRefs.has(droppedKey)
return (
diff --git a/apps/sim/lib/content/faq.tsx b/apps/sim/lib/content/faq.tsx
index 327440ae740..46190693d18 100644
--- a/apps/sim/lib/content/faq.tsx
+++ b/apps/sim/lib/content/faq.tsx
@@ -1,21 +1,38 @@
export function FAQ({ items }: { items: { q: string; a: string }[] }) {
if (!items || items.length === 0) return null
+ const occurrences = new Map()
return (
FAQ
- {items.map((it, i) => (
-
-
- {it.q}
-
-
-
- {it.a}
-
+ {items.map((it) => {
+ const signature = `${it.q}\u0000${it.a}`
+ const occurrence = occurrences.get(signature) ?? 0
+ occurrences.set(signature, occurrence + 1)
+ return (
+
+
+ {it.q}
+
+
+
+ {it.a}
+
+
-
- ))}
+ )
+ })}
)
diff --git a/packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx b/packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx
index cd983b326fd..c56711e7e68 100644
--- a/packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx
+++ b/packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx
@@ -1,6 +1,7 @@
'use client'
import * as React from 'react'
+import { generateShortId } from '@sim/utils/id'
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
import { TagInput, type TagItem } from '../tag-input/tag-input'
@@ -84,7 +85,7 @@ export function ChipEmailsInput({
id,
}: ChipEmailsInputProps) {
const [items, setItems] = React.useState(() =>
- value.map((v) => ({ value: v, isValid: true }))
+ value.map((v) => ({ id: generateShortId(), value: v, isValid: true }))
)
/**
@@ -113,7 +114,7 @@ export function ChipEmailsInput({
if (prevValid.length === value.length && prevValid.every((v, idx) => v === value[idx])) {
return
}
- itemsRef.current = value.map((v) => ({ value: v, isValid: true }))
+ itemsRef.current = value.map((v) => ({ id: generateShortId(), value: v, isValid: true }))
setItems(itemsRef.current)
}, [value])
@@ -128,6 +129,7 @@ export function ChipEmailsInput({
commitItems([
...current,
{
+ id: generateShortId(),
value: email,
isValid: false,
error: allowDomains ? 'Invalid email or domain' : 'Invalid email format',
@@ -138,11 +140,14 @@ export function ChipEmailsInput({
const reason = validate?.(email)
if (reason) {
- commitItems([...current, { value: email, isValid: false, error: reason }])
+ commitItems([
+ ...current,
+ { id: generateShortId(), value: email, isValid: false, error: reason },
+ ])
return false
}
- const next = [...current, { value: email, isValid: true }]
+ const next = [...current, { id: generateShortId(), value: email, isValid: true }]
commitItems(next)
onChange(next.filter((item) => item.isValid).map((item) => item.value))
return true
diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx
index 95eead03fbd..a698b706683 100644
--- a/packages/emcn/src/components/chip-modal/chip-modal.tsx
+++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx
@@ -1048,9 +1048,14 @@ export interface ChipModalFooterCustomAction {
custom: React.ReactNode
}
-/** One entry of the footer's left-docked `secondaryActions` cluster. */
+/** Declarative or custom action accepted by a footer slot. */
export type ChipModalFooterSlotAction = ChipModalFooterAction | ChipModalFooterCustomAction
+/** A footer slot action with the stable identity required by `secondaryActions`. */
+export type ChipModalFooterSecondaryAction =
+ | (ChipModalFooterAction & { id: string })
+ | (ChipModalFooterCustomAction & { id: string })
+
export interface ChipModalFooterProps {
/**
* Dismiss handler for the Cancel button. For standard form footers Cancel is
@@ -1103,7 +1108,7 @@ export interface ChipModalFooterProps {
* each entry is a constrained {@link ChipModalFooterSlotAction} — consumers
* describe intent, never chrome.
*/
- secondaryActions?: ChipModalFooterSlotAction[]
+ secondaryActions?: ChipModalFooterSecondaryAction[]
}
/**
@@ -1217,8 +1222,8 @@ function ChipModalFooter({
leftSlot={
secondaryActions && secondaryActions.length > 0 ? (
- {secondaryActions.map((action, index) => (
- {renderFooterSlotAction(action)}
+ {secondaryActions.map((action) => (
+ {renderFooterSlotAction(action)}
))}
) : undefined
diff --git a/packages/emcn/src/components/chip-select/chip-select.tsx b/packages/emcn/src/components/chip-select/chip-select.tsx
index 94b4da1885e..76645bc8a05 100644
--- a/packages/emcn/src/components/chip-select/chip-select.tsx
+++ b/packages/emcn/src/components/chip-select/chip-select.tsx
@@ -181,6 +181,14 @@ export function ChipSelect({
.filter((g) => g.items.length > 0)
}, [searchable, query, sections])
+ const sectionOccurrences = new Map()
+ const keyedSections = filteredSections.map((group) => {
+ const signature = group.section ?? group.items.map((item) => item.value).join('\u0000')
+ const occurrence = sectionOccurrences.get(signature) ?? 0
+ sectionOccurrences.set(signature, occurrence + 1)
+ return { group, key: `${signature}\u0000${occurrence}` }
+ })
+
const hasResults = filteredSections.some((g) => g.items.length > 0)
const toggleValue = (val: string) => {
@@ -293,8 +301,8 @@ export function ChipSelect({
) : null}
{hasResults ? (
- filteredSections.map((group, index) => (
-
+ keyedSections.map(({ group, key }) => (
+
{group.section ? {group.section} : null}
{group.items.map(renderOption)}
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index 1f4f62aba98..14123f06561 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -69,6 +69,7 @@ export {
type ChipModalFooterAction,
type ChipModalFooterCustomAction,
type ChipModalFooterProps,
+ type ChipModalFooterSecondaryAction,
type ChipModalFooterSlotAction,
ChipModalHeader,
type ChipModalHeaderProps,
diff --git a/packages/emcn/src/components/tag-input/tag-input.tsx b/packages/emcn/src/components/tag-input/tag-input.tsx
index 3e5a99eddc9..573aa2034b7 100644
--- a/packages/emcn/src/components/tag-input/tag-input.tsx
+++ b/packages/emcn/src/components/tag-input/tag-input.tsx
@@ -4,6 +4,7 @@
* @example
* ```tsx
* import { TagInput, type TagItem } from '../../index'
+ * import { generateShortId } from '@sim/utils/id'
*
* const [items, setItems] = useState([])
*
@@ -11,7 +12,7 @@
* items={items}
* onAdd={(value) => {
* const isValid = isValidEmail(value)
- * setItems(prev => [...prev, { value, isValid }])
+ * setItems(prev => [...prev, { id: generateShortId(), value, isValid }])
* return isValid
* }}
* onRemove={(value, index) => {
@@ -81,6 +82,8 @@ const tagInputVariants = cva(
* Represents a tag item with its value and validity status.
*/
export interface TagItem {
+ /** Stable identity retained while the tag is edited, reordered, or removed. */
+ id: string
value: string
isValid: boolean
/**
@@ -402,7 +405,7 @@ const TagInput = React.forwardRef(
)}
{items.map((item, index) => (
()
return (
{segments.map((segment, index) => {
+ const signature =
+ typeof segment === 'string'
+ ? `text:${segment}`
+ : `value:${segment.subBlockId}:${segment.noun ?? ''}`
+ const occurrence = occurrences.get(signature) ?? 0
+ occurrences.set(signature, occurrence + 1)
+ const key = `${signature}:${occurrence}`
if (typeof segment === 'string') {
const hugsPrevious = segment.startsWith(',') || segment.startsWith('.')
const glue = index > 0 && !hugsPrevious ? ' ' : ''
- return {`${glue}${segment}`}
+ return {`${glue}${segment}`}
}
/* A core slot keeps its place with the field's noun; an optional one
@@ -64,7 +72,7 @@ export function CanvasSentenceView({ segments, renderChip }: CanvasSentenceViewP
/* The space before `{chip}` is significant — JSX keeps whitespace
between text and an expression on the same line. It is what
separates a chip from the copy in front of it. */
- return {chip}
+ return {chip}
})}
)
From 67fd9abc72d8f4316f92b90229a8f838ba86004a Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 19 Aug 2026 10:14:28 -0700
Subject: [PATCH 2/2] Address PR review feedback (#6840)
- assign generated IDs to externally appended PII pattern rows
- cover editing appended rows without remounting
---
.../components/pii/custom-patterns-editor.test.tsx | 12 ++++++++++--
apps/sim/components/pii/custom-patterns-editor.tsx | 10 +++++++++-
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/apps/sim/components/pii/custom-patterns-editor.test.tsx b/apps/sim/components/pii/custom-patterns-editor.test.tsx
index cbe2a9c282a..a7899fb3727 100644
--- a/apps/sim/components/pii/custom-patterns-editor.test.tsx
+++ b/apps/sim/components/pii/custom-patterns-editor.test.tsx
@@ -82,7 +82,7 @@ describe('CustomPatternsEditor', () => {
expect(onChange).toHaveBeenCalledWith([{ name: 'X', regex: 'b+', replacement: '' }])
})
- it('preserves an existing row while rows are appended and truncated', () => {
+ it('preserves rows while they are appended, edited, and truncated', () => {
const onChange = vi.fn()
const firstPattern = row('a+')
renderEditor([firstPattern], onChange)
@@ -90,10 +90,18 @@ describe('CustomPatternsEditor', () => {
const firstRegexInput = container.querySelector('input[value="a+"]') as HTMLInputElement
firstRegexInput.focus()
- renderEditor([firstPattern, row('b+')], onChange)
+ const secondPattern = row('b+')
+ renderEditor([firstPattern, secondPattern], onChange)
expect(container.querySelector('input[value="a+"]')).toBe(firstRegexInput)
expect(document.activeElement).toBe(firstRegexInput)
+ const secondRegexInput = container.querySelector('input[value="b+"]') as HTMLInputElement
+ secondRegexInput.focus()
+ renderEditor([firstPattern, { ...secondPattern, regex: 'b*' }], onChange)
+ expect(container.querySelector('input[value="b*"]')).toBe(secondRegexInput)
+ expect(document.activeElement).toBe(secondRegexInput)
+
+ firstRegexInput.focus()
renderEditor([firstPattern], onChange)
expect(container.querySelector('input[value="a+"]')).toBe(firstRegexInput)
expect(document.activeElement).toBe(firstRegexInput)
diff --git a/apps/sim/components/pii/custom-patterns-editor.tsx b/apps/sim/components/pii/custom-patterns-editor.tsx
index 1cdbbfdbb68..b548b41d4e5 100644
--- a/apps/sim/components/pii/custom-patterns-editor.tsx
+++ b/apps/sim/components/pii/custom-patterns-editor.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { Chip, ChipInput } from '@sim/emcn'
import { Plus, Trash } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
@@ -28,6 +28,14 @@ interface CustomPatternsEditorProps {
export function CustomPatternsEditor({ patterns, onChange }: CustomPatternsEditorProps) {
const [patternIds, setPatternIds] = useState(() => patterns.map(() => generateShortId()))
+ useEffect(() => {
+ setPatternIds((current) => {
+ if (current.length === patterns.length) return current
+ if (current.length > patterns.length) return current.slice(0, patterns.length)
+ return [...current, ...patterns.slice(current.length).map(() => generateShortId())]
+ })
+ }, [patterns.length])
+
function updateRow(index: number, patch: Partial) {
onChange(patterns.map((pattern, i) => (i === index ? { ...pattern, ...patch } : pattern)))
}