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) {
- {titleLines.map((line, index) => ( - {line} + {titleLines.map((line) => ( + {line.text} ))}
, diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx index 15bfeb5c049..4d024a1d3e4 100644 --- a/apps/docs/components/workflow-preview/format-references.tsx +++ b/apps/docs/components/workflow-preview/format-references.tsx @@ -10,16 +10,19 @@ const REFERENCE_PATTERN = /(<[^<>]+>|\{\{[^{}]+\}\})/g */ export function formatReferences(text: string): ReactNode[] { if (!text) return [] - return text.split(REFERENCE_PATTERN).map((part, index) => { + let sourceOffset = 0 + return text.split(REFERENCE_PATTERN).map((part) => { + const partOffset = sourceOffset + sourceOffset += part.length if (!part) return null const isReference = (part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}')) return isReference ? ( - + {part} ) : ( - {part} + {part} ) }) } diff --git a/apps/sim/app/(interfaces)/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx index abb1460c955..44aa77bf8bd 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -21,6 +21,11 @@ interface AttachedFile { dataUrl?: string } +interface UploadError { + id: string + message: string +} + export const ChatInput: React.FC<{ onSubmit?: (value: string, files?: AttachedFile[]) => void isStreaming?: boolean @@ -30,7 +35,7 @@ export const ChatInput: React.FC<{ const textareaRef = useRef(null) const [inputValue, setInputValue] = useState('') const [attachedFiles, setAttachedFiles] = useState([]) - const [uploadErrors, setUploadErrors] = useState([]) + const [uploadErrors, setUploadErrors] = useState([]) const [dragCounter, setDragCounter] = useState(0) const isDragOver = dragCounter > 0 @@ -54,7 +59,10 @@ export const ChatInput: React.FC<{ const file = selectedFiles[i] if (file.size > maxSize) { - setUploadErrors((prev) => [...prev, `${file.name} is too large (max 10MB)`]) + setUploadErrors((prev) => [ + ...prev, + { id: generateId(), message: `${file.name} is too large (max 10MB)` }, + ]) continue } @@ -62,7 +70,10 @@ export const ChatInput: React.FC<{ (existing) => existing.name === file.name && existing.size === file.size ) if (isDuplicate) { - setUploadErrors((prev) => [...prev, `${file.name} already added`]) + setUploadErrors((prev) => [ + ...prev, + { id: generateId(), message: `${file.name} already added` }, + ]) continue } @@ -128,9 +139,9 @@ export const ChatInput: React.FC<{
{uploadErrors.length > 0 && (
- {uploadErrors.map((error, idx) => ( - - {error} + {uploadErrors.map((error) => ( + + {error.message} ))}
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-home.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-home.tsx index 1b576a5df87..df434978e09 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-home.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-home.tsx @@ -114,11 +114,11 @@ const Caret = () => ( function PromptAtoms({ atoms }: { atoms: PromptAtom[] }) { return ( <> - {atoms.map((atom, i) => + {atoms.map((atom) => atom.kind === 'char' ? ( - {atom.char} + {atom.char} ) : ( - + @ diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-data.ts b/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-data.ts index 48c4f075daa..4d2259ececf 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-data.ts +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-data.ts @@ -238,8 +238,8 @@ export const SCENE_JIRA_FOCUS_TRANSLATE = { x: -645, y: 0 } as const * `@GitHub` / `@Jira` mention. */ export type PromptAtom = - | { kind: 'char'; char: string } - | { kind: 'mention'; label: string; icon: IconComponent } + | { id: string; kind: 'char'; char: string } + | { id: string; kind: 'mention'; label: string; icon: IconComponent } const PROMPT_SEGMENTS: Array = [ 'Create me a ', @@ -248,11 +248,26 @@ const PROMPT_SEGMENTS: Array = { label: 'Jira', icon: JiraIcon }, ] -export const PROMPT_ATOMS: PromptAtom[] = PROMPT_SEGMENTS.flatMap((seg) => - typeof seg === 'string' - ? [...seg].map((char): PromptAtom => ({ kind: 'char', char })) - : [{ kind: 'mention', label: seg.label, icon: seg.icon } as PromptAtom] -) +let promptSourceOffset = 0 +export const PROMPT_ATOMS: PromptAtom[] = PROMPT_SEGMENTS.flatMap((seg) => { + if (typeof seg === 'string') { + const atoms = [...seg].map((char): PromptAtom => { + const id = `char-${promptSourceOffset}` + promptSourceOffset += char.length + return { id, kind: 'char', char } + }) + return atoms + } + + const atom: PromptAtom = { + id: `mention-${promptSourceOffset}-${seg.label}`, + kind: 'mention', + label: seg.label, + icon: seg.icon, + } + promptSourceOffset += seg.label.length + 1 + return [atom] +}) /** Greeting shown above the input in the home state (matches the Mothership home). */ export const HOME_GREETING = 'What should we get done?' diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx index 027fa0c6157..57002dd488a 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx @@ -287,22 +287,27 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({ * Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing. */ function NoteMarkdown({ content }: { content: string }) { - const lines = content.split('\n') + let sourceOffset = 0 + const lines = content.split('\n').map((text) => { + const line = { sourceOffset, text } + sourceOffset += text.length + 1 + return line + }) return (
- {lines.map((line, i) => { - const trimmed = line.trim() - if (!trimmed) return
+ {lines.map((line) => { + const trimmed = line.text.trim() + if (!trimmed) return
if (trimmed === '---') { - return
+ return
} if (trimmed.startsWith('### ')) { return (

{trimmed.slice(4)} @@ -312,7 +317,7 @@ function NoteMarkdown({ content }: { content: string }) { return (

{block.content}

case 'subheading': return

{block.text}

- case 'list': + case 'list': { + const itemOccurrences = new Map() return (
    - {block.items.map((item, index) => { - const itemKey = `item-${index}` + {block.items.map((item) => { + const signature = extractTextContent(item) + const occurrence = itemOccurrences.get(signature) ?? 0 + itemOccurrences.set(signature, occurrence + 1) return ( -
  • +
  • {item}
  • ) })}
) + } case 'callout': return
{block.content}
case 'table': diff --git a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/legal-block-group.tsx b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/legal-block-group.tsx index bc4d16e7e1a..49614ae5c67 100644 --- a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/legal-block-group.tsx +++ b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/legal-block-group.tsx @@ -1,4 +1,5 @@ import { cn } from '@sim/emcn' +import { extractTextContent } from '@/lib/core/utils/react-node-text' import { LegalBlockView } from '@/app/(landing)/components/prose-page/components/legal-block-group/components' import { PROSE_SPACING } from '@/app/(landing)/components/prose-page/constants' import type { LegalBlock } from '@/app/(landing)/components/prose-page/types' @@ -16,11 +17,21 @@ interface LegalBlockGroupProps { } export function LegalBlockGroup({ blocks }: LegalBlockGroupProps) { + const blockOccurrences = new Map() return (
- {blocks.map((block, index) => ( - - ))} + {blocks.map((block) => { + const content = + block.kind === 'subheading' + ? block.text + : block.kind === 'list' + ? block.items.map(extractTextContent).join('\u0000') + : extractTextContent(block.content) + const signature = `${block.kind}:${content}` + const occurrence = blockOccurrences.get(signature) ?? 0 + blockOccurrences.set(signature, occurrence + 1) + return + })}
) } diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/build-methods-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/build-methods-graphic.tsx index c3d6a9db162..7f6bcfe7ab7 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/build-methods-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/build-methods-graphic.tsx @@ -13,7 +13,7 @@ interface CodeSegment { } /** The `support-agent.ts` contents, split into tone-colored typewriter segments. */ -const CODE_LINES: CodeSegment[][] = [ +const RAW_CODE_LINES: CodeSegment[][] = [ [ { text: 'import', tone: 'muted' }, { text: ' ' }, @@ -52,8 +52,24 @@ const CODE_LINES: CodeSegment[][] = [ [{ text: ' })' }], ] +const codeLineOccurrences = new Map() +const CODE_LINES = RAW_CODE_LINES.map((segments) => { + const text = segments.map((segment) => segment.text).join('') + const occurrence = codeLineOccurrences.get(text) ?? 0 + codeLineOccurrences.set(text, occurrence + 1) + let sourceOffset = 0 + return { + id: `${text}:${occurrence}`, + segments: segments.map((segment) => { + const id = `${sourceOffset}:${segment.text.length}` + sourceOffset += segment.text.length + return { ...segment, id } + }), + } +}) + const CODE_LINE_LENGTHS = CODE_LINES.map((line) => - line.reduce((total, segment) => total + segment.text.length, 0) + line.segments.reduce((total, segment) => total + segment.text.length, 0) ) const CODE_LINE_STARTS = CODE_LINE_LENGTHS.map((_, index) => CODE_LINE_LENGTHS.slice(0, index).reduce((total, length) => total + length, 0) @@ -93,13 +109,13 @@ const SEGMENT_TONE_CLASS = { } as const /** Renders one code line clipped to the number of characters typed so far. */ -function renderCodeLine(segments: CodeSegment[], visibleChars: number) { +function renderCodeLine(segments: Array, visibleChars: number) { const rendered = [] let remaining = visibleChars for (let index = 0; index < segments.length && remaining > 0; index++) { const segment = segments[index] rendered.push( - + {segment.text.slice(0, remaining)} ) @@ -255,12 +271,12 @@ export function BuildMethodsGraphic() {
{CODE_LINES.map((line, index) => typedCodeChars > CODE_LINE_STARTS[index] ? ( -
+
{index + 1} - {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 }) => ( {rows.map((row, ri) => ( - {headers.map((_, ci) => ( + {columns.map(({ id, 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} + + ) + })}
) : (
editConfig && startEdit(-1, i, String(header ?? ''))} + onClick={() => editConfig && startEdit(-1, columnIndex, String(header ?? ''))} > - {isEditing(-1, i) ? ( + {isEditing(-1, columnIndex) ? ( (function DataT
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] ?? '') )}