From cacac577ce17ae82984fb081379a1247920ca483 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 14:38:50 -0700 Subject: [PATCH 01/32] feat(rich-editor): rich markdown field + @ mentions for skill & deploy modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add controlled, file-less RichMarkdownField (sibling of the file editor) used for skill Content and deploy version descriptions; placeholder/typography match chip fields - Add @-mention menu (TipTap suggestion) inserting portable [label](sim:kind/id) links; wired into the field and the file viewer via a shared useEditorMentions hook - Extract a shared suggestion-popup renderer + menu chrome (slash + mention) - Fix false dirty-on-open: normalize the editor's dirty baseline to canonical markdown - Always show the deployment version number (v3 · name) so named versions keep a short ref - Skill import: drop the paste box (Create-tab editor auto-destructures a pasted SKILL.md), reorder GitHub → Upload --- .../dirty-on-open.test.ts | 104 +++++++++++ .../rich-markdown-editor/extensions.ts | 11 +- .../rich-markdown-editor/mention/index.ts | 5 + .../mention/mention-list.tsx | 165 ++++++++++++++++++ .../mention/mention-store.ts | 32 ++++ .../rich-markdown-editor/mention/mention.ts | 89 ++++++++++ .../mention/sim-link.test.ts | 34 ++++ .../rich-markdown-editor/mention/sim-link.ts | 46 +++++ .../rich-markdown-editor/mention/types.ts | 29 +++ .../mention/use-editor-mentions.ts | 27 +++ .../mention/use-markdown-mentions.ts | 99 +++++++++++ .../menus/suggestion-menu-chrome.ts | 21 +++ .../menus/suggestion-popup.ts | 99 +++++++++++ .../rich-markdown-editor/normalize-content.ts | 23 +++ .../rich-markdown-editor.css | 14 ++ .../rich-markdown-editor.tsx | 13 ++ .../rich-markdown-field.tsx | 164 +++++++++++++++++ .../rich-markdown-editor/round-trip.test.ts | 7 + .../slash-command/slash-command-list.tsx | 29 ++- .../slash-command/slash-command.ts | 82 ++------- .../file-viewer/use-editable-file-content.ts | 30 +++- .../components/skill-import/skill-import.tsx | 117 ++++--------- .../components/skill-modal/skill-modal.tsx | 83 ++++++--- .../components/version-description-modal.tsx | 48 +++-- .../general/components/versions.tsx | 26 ++- .../general/format-version-label.ts | 8 + .../components/general/general.tsx | 23 ++- 27 files changed, 1205 insertions(+), 223 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts new file mode 100644 index 00000000000..7c45efe601f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment jsdom + * + * Regression guards for two bugs found while adding the `@` mention menu: + * + * 1. The `@` mention and `/` slash-command extensions each register a `@tiptap/suggestion` plugin. + * They must use distinct plugin keys, or constructing any editor with the full set throws + * "Adding different instances of a keyed plugin (suggestion$)". + * + * 2. A markdown file authored outside the editor (e.g. the former Monaco editor) is rarely in the + * editor's canonical serialization. On open, a deferred view-plugin transaction re-serializes the + * doc to canonical markdown and emits one update — which, compared against the raw saved bytes, + * falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to + * the canonical form; this asserts that normalized form equals what the live editor emits. + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { createMarkdownEditorExtensions } from './extensions' +import { + applyFrontmatter, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' +import { parseMarkdownToDoc } from './markdown-parse' +import { normalizeMarkdownContent } from './normalize-content' + +let editor: Editor | null = null +let host: HTMLElement | null = null + +beforeAll(() => { + // jsdom lacks the layout APIs the Placeholder viewport plugin calls when a view mounts. + // @ts-expect-error jsdom stub + document.elementFromPoint = () => document.body + // @ts-expect-error jsdom stub + Range.prototype.getClientRects = () => [] as unknown as DOMRectList + Range.prototype.getBoundingClientRect = () => new DOMRect() + Element.prototype.getClientRects = () => [] as unknown as DOMRectList +}) + +afterEach(() => { + editor?.destroy() + editor = null + host?.remove() + host = null +}) + +describe('full extension set', () => { + it('mounts without a duplicate suggestion-plugin-key error (@ and / coexist)', () => { + expect(() => { + editor = new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: 'x' }), + content: '', + }) + }).not.toThrow() + }) +}) + +describe('normalizeMarkdownContent — dirty-on-open baseline', () => { + it('normalizes non-canonical markdown to the editor canonical form', () => { + expect(normalizeMarkdownContent('* one\n* two\n')).toBe('- one\n- two\n') + }) + + it('is idempotent', () => { + for (const md of [ + '* one\n* two\n', + '| a | b |\n| --- | --- |\n| 1 | 2 |\n', + '# H\n\nsome _emphasis_ here\n', + ]) { + const once = normalizeMarkdownContent(md) + expect(normalizeMarkdownContent(once)).toBe(once) + } + }) + + it('leaves round-trip-unsafe content untouched (read-only files keep their raw bytes)', () => { + const unsafe = 'text with a footnote[^1]\n\n[^1]: the note\n' + expect(normalizeMarkdownContent(unsafe)).toBe(unsafe) + }) +}) + +describe('baseline neutralizes the mount-time dirty signal', () => { + it('the editor mount serialization equals the normalized baseline (so isDirty stays false)', async () => { + const raw = '# H\n\n* bullet\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quote\n' + const { frontmatter, body } = splitFrontmatter(raw) + host = document.createElement('div') + document.body.appendChild(host) + + let emitted: string | null = null + editor = new Editor({ + element: host, + extensions: createMarkdownEditorExtensions({ placeholder: 'x' }), + content: parseMarkdownToDoc(body), + onUpdate: ({ editor }) => { + emitted = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown())) + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 30)) + + // The deferred mount transaction re-serializes to canonical markdown; the baseline must match it + // exactly, so `content === savedContent` and the file is never falsely dirty on open. + expect(emitted).not.toBeNull() + expect(emitted).toBe(normalizeMarkdownContent(raw)) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 2610daac7b4..defb1332247 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -17,8 +17,16 @@ import { MarkdownImage, ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownPaste } from './markdown-paste' +import { Mention, SIM_LINK_SCHEME } from './mention' import { SlashCommand } from './slash-command/slash-command' +/** + * The `@`-mention link scheme, registered on the Link mark — without it the schema strips the + * `sim:/` href on parse/round-trip, dropping the mention. `optionalSlashes` allows the + * slash-less `sim:kind/id` form. + */ +const SIM_LINK_PROTOCOL = { scheme: SIM_LINK_SCHEME, optionalSlashes: true } as const + /** * Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`). * The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and @@ -78,7 +86,7 @@ export function createMarkdownContentExtensions({ }) return [ StarterKit.configure({ - link: { openOnClick: false }, + link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] }, underline: false, codeBlock: false, code: false, @@ -109,6 +117,7 @@ export function createMarkdownEditorExtensions({ ...createMarkdownContentExtensions({ nodeViews: true }), CodeBlockHighlight, SlashCommand, + Mention, RichMarkdownKeymap, MarkdownPaste, Placeholder.configure({ placeholder }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts new file mode 100644 index 00000000000..361f282333d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -0,0 +1,5 @@ +export { Mention, type MentionStorage } from './mention' +export { parseSimHref, SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' +export type { MentionItem, MentionKind } from './types' +export { useEditorMentions } from './use-editor-mentions' +export { useMarkdownMentions } from './use-markdown-mentions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx new file mode 100644 index 00000000000..2e892ff4565 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -0,0 +1,165 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { cn } from '@/lib/core/utils/cn' +import { + SUGGESTION_GROUP_LABEL_CLASS, + SUGGESTION_ITEM_CLASS, + SUGGESTION_SCROLL_CLASS, + SUGGESTION_SURFACE_CLASS, +} from '../menus/suggestion-menu-chrome' +import type { MentionStore } from './mention-store' +import type { MentionItem } from './types' + +export interface MentionListHandle { + onKeyDown: (props: { event: KeyboardEvent }) => boolean +} + +interface MentionListProps { + /** The text typed after `@`, used to filter. */ + query: string + /** Inserts the chosen mention (wired to the suggestion `command`). */ + command: (item: MentionItem) => void + /** Live data source the host keeps populated. */ + store: MentionStore +} + +/** Per-group cap so a large workspace can't flood the menu; filtering still searches the full set. */ +const MAX_PER_GROUP = 8 + +/** Category heading order in the menu. */ +const GROUP_ORDER = [ + 'Files', + 'Folders', + 'Tables', + 'Knowledge bases', + 'Workflows', + 'Skills', + 'Integrations', +] as const + +/** + * The `@` mention popup. Sibling of {@link SlashCommandList} with identical chrome and arrow/enter + * navigation, but its items come reactively from the editor's {@link MentionStore} (via + * `useSyncExternalStore`) rather than props — so the list fills in as async workspace data lands. + */ +export const MentionList = forwardRef(function MentionList( + { query, command, store }, + ref +) { + const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) + const [activeIndex, setActiveIndex] = useState(0) + const containerRef = useRef(null) + + /** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */ + const { flat, groups } = useMemo(() => { + const q = query.trim().toLowerCase() + // One pass over the full set: filter by label and bucket by group (capped), then read the + // buckets in category order — avoids a separate filter pass per group. + const byGroup = new Map() + for (const item of rawItems) { + if (q && !item.label.toLowerCase().includes(q)) continue + const bucket = byGroup.get(item.group) + if (!bucket) byGroup.set(item.group, [item]) + else if (bucket.length < MAX_PER_GROUP) bucket.push(item) + } + + const ordered: { group: string; items: { item: MentionItem; index: number }[] }[] = [] + const flat: MentionItem[] = [] + for (const group of GROUP_ORDER) { + const inGroup = byGroup.get(group) + if (!inGroup) continue + ordered.push({ group, items: inGroup.map((item) => ({ item, index: flat.push(item) - 1 })) }) + } + return { flat, groups: ordered } + }, [rawItems, query]) + + useEffect(() => { + setActiveIndex(0) + }, [flat]) + + useEffect(() => { + containerRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (flat.length === 0) return false + if (event.key === 'ArrowUp') { + setActiveIndex((i) => (i + flat.length - 1) % flat.length) + return true + } + if (event.key === 'ArrowDown') { + setActiveIndex((i) => (i + 1) % flat.length) + return true + } + if (event.key === 'Enter') { + const item = flat[activeIndex] + if (!item) return false + command(item) + return true + } + return false + }, + })) + + if (flat.length === 0) { + return ( +
+

+ {rawItems.length === 0 ? 'Loading…' : 'No results'} +

+
+ ) + } + + return ( +
+ {groups.map((group) => ( +
+ + {group.items.map(({ item, index }) => { + const Icon = item.icon + return ( + + ) + })} +
+ ))} +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts new file mode 100644 index 00000000000..f3ac59d24b3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts @@ -0,0 +1,32 @@ +import type { MentionItem } from './types' + +/** + * A tiny external store bridging React Query data (host component) into the `@` menu list, which is + * rendered by TipTap's `ReactRenderer` as a detached root with no access to the app's React context + * providers. The host pushes the latest items via {@link MentionStore.set}; the list subscribes with + * `useSyncExternalStore` and re-renders when async data lands — so the menu populates live even if it + * was opened before the data finished loading. One store instance lives per editor (in extension + * storage). + */ +export interface MentionStore { + getSnapshot: () => MentionItem[] + subscribe: (listener: () => void) => () => void + set: (items: MentionItem[]) => void +} + +export function createMentionStore(): MentionStore { + let items: MentionItem[] = [] + const listeners = new Set<() => void>() + return { + getSnapshot: () => items, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + set: (next) => { + if (next === items) return + items = next + for (const listener of listeners) listener() + }, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts new file mode 100644 index 00000000000..498a3212197 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -0,0 +1,89 @@ +import { Extension } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' +import Suggestion from '@tiptap/suggestion' +import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' +import { MentionList } from './mention-list' +import { createMentionStore, type MentionStore } from './mention-store' +import { toSimHref } from './sim-link' +import type { MentionItem } from './types' + +/** Distinct from the `/` slash command's default `suggestion` key — two plugins can't share one key. */ +const MENTION_PLUGIN_KEY = new PluginKey('mention') + +/** + * Per-editor storage for the `@` mention extension. The host component populates {@link store} with + * the current workspace mention data and may set {@link onOpen} to lazily start fetching that data the + * first time the menu is triggered. {@link enabled} gates the menu off entirely (e.g. a field with no + * workspace scope) so `@` stays literal text. + */ +export interface MentionStorage { + store: MentionStore + onOpen: (() => void) | null + enabled: boolean +} + +declare module '@tiptap/core' { + interface Storage { + mention: MentionStorage + } +} + +/** + * Adds the `@` mention menu to the editor. Typing `@` at the start of a block — or after whitespace — + * opens {@link MentionList}; selecting an entity inserts it as a portable `sim:/` markdown + * link (same wire format as the chat composer's `chip-clipboard-codec`), so it round-trips natively + * through the editor's link + markdown machinery. The menu's data is supplied by the host via the + * extension's `mention` storage. + */ +export const Mention = Extension.create, MentionStorage>({ + name: 'mention', + + addStorage() { + return { store: createMentionStore(), onOpen: null, enabled: true } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + pluginKey: MENTION_PLUGIN_KEY, + char: '@', + allowSpaces: false, + startOfLine: false, + allow: ({ editor, range }) => { + if (!editor.storage.mention.enabled) return false + if (editor.isActive('codeBlock') || editor.isActive('link') || editor.isActive('code')) { + return false + } + const $from = editor.state.doc.resolve(range.from) + if ($from.parentOffset === 0) return true + // Only after whitespace, so `@` inside an email/handle (`name@host`) never triggers. + return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset)) + }, + // Items are sourced reactively from the store inside MentionList; this only gates the plugin. + items: () => [], + command: ({ editor, range, props }) => { + const href = toSimHref(props.kind, props.id) + editor + .chain() + .focus() + .deleteRange(range) + .insertContent([ + { type: 'text', text: props.label, marks: [{ type: 'link', attrs: { href } }] }, + { type: 'text', text: ' ' }, + ]) + .run() + }, + render: createSuggestionPopupRenderer({ + component: MentionList, + mapProps: (props) => ({ + query: props.query, + command: props.command, + store: props.editor.storage.mention.store, + }), + onOpen: (props) => props.editor.storage.mention.onOpen?.(), + }), + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts new file mode 100644 index 00000000000..e33a4359f28 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { parseSimHref, simLinkPath } from './sim-link' + +describe('parseSimHref', () => { + it('parses a sim mention href', () => { + expect(parseSimHref('sim:file/abc-123')).toEqual({ kind: 'file', id: 'abc-123' }) + expect(parseSimHref('sim:knowledge/kb_1')).toEqual({ kind: 'knowledge', id: 'kb_1' }) + }) + + it('returns null for non-sim hrefs', () => { + expect(parseSimHref('https://sim.ai')).toBeNull() + expect(parseSimHref('sim:file')).toBeNull() + expect(parseSimHref('mailto:x@y.com')).toBeNull() + }) +}) + +describe('simLinkPath', () => { + const ws = 'ws1' + + // Each destination must match a real route — skills/folders deep-link via query params (no [id] route). + it('resolves every kind to its real in-app route', () => { + expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1/view') + expect(simLinkPath(ws, 'folder', 'd1')).toBe('/workspace/ws1/files?folderId=d1') + expect(simLinkPath(ws, 'table', 't1')).toBe('/workspace/ws1/tables/t1') + expect(simLinkPath(ws, 'knowledge', 'k1')).toBe('/workspace/ws1/knowledge/k1') + expect(simLinkPath(ws, 'workflow', 'w1')).toBe('/workspace/ws1/w/w1') + expect(simLinkPath(ws, 'skill', 's1')).toBe('/workspace/ws1/skills?skillId=s1') + expect(simLinkPath(ws, 'integration', 'slack')).toBe('/workspace/ws1/integrations/slack') + }) + + it('returns null for an unknown kind', () => { + expect(simLinkPath(ws, 'mystery', 'x')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts new file mode 100644 index 00000000000..cfb526f44bd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts @@ -0,0 +1,46 @@ +/** + * The link scheme for `@`-mention links — `[label](sim:/)`. Matches the chat composer's + * portable chip format (`chip-clipboard-codec.ts`), so a mention authored here is parseable there. + */ +export const SIM_LINK_SCHEME = 'sim' + +/** A bare `sim:/` mention href (the link target inserted by the `@` menu). */ +const SIM_HREF_PATTERN = /^sim:([a-z_]+)\/(.+)$/ + +/** Builds the link target for a mention of `kind`/`id`. */ +export function toSimHref(kind: string, id: string): string { + return `${SIM_LINK_SCHEME}:${kind}/${id}` +} + +/** Parses a `sim:/` href into its parts, or `null` if it isn't a sim mention link. */ +export function parseSimHref(href: string): { kind: string; id: string } | null { + const match = href.match(SIM_HREF_PATTERN) + return match ? { kind: match[1], id: match[2] } : null +} + +/** + * Resolves the in-app route for a clicked `sim:` mention link, or `null` for an unknown kind. Each + * destination matches the entity's real route: files open the file detail view, folders/skills deep-link + * the file browser / skills modal via their query params, the rest hit their `[id]` route. + */ +export function simLinkPath(workspaceId: string, kind: string, id: string): string | null { + const base = `/workspace/${workspaceId}` + switch (kind) { + case 'file': + return `${base}/files/${id}/view` + case 'folder': + return `${base}/files?folderId=${id}` + case 'table': + return `${base}/tables/${id}` + case 'knowledge': + return `${base}/knowledge/${id}` + case 'workflow': + return `${base}/w/${id}` + case 'skill': + return `${base}/skills?skillId=${id}` + case 'integration': + return `${base}/integrations/${id}` + default: + return null + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts new file mode 100644 index 00000000000..dd87663dcc7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts @@ -0,0 +1,29 @@ +import type { ComponentType } from 'react' + +/** + * The workspace entity kinds that can be `@`-mentioned in a markdown editor. A deliberate subset of + * the chat's portable kinds (`chip-clipboard-codec.ts`) — the workspace-scoped ones that exist + * without a workflow runtime context. The string values match that codec's `sim:/` scheme, + * so a mention link inserted here is parseable by `parseChipLinks()`. + */ +export type MentionKind = + | 'file' + | 'folder' + | 'table' + | 'knowledge' + | 'workflow' + | 'skill' + | 'integration' + +/** A single selectable entry in the `@` menu. */ +export interface MentionItem { + kind: MentionKind + /** Entity id used as `sim:/` in the inserted link. */ + id: string + /** Display + link text. */ + label: string + /** Category heading the item is shown under. */ + group: string + /** Optional per-item icon (Lucide category icon or a brand block icon). */ + icon?: ComponentType<{ className?: string }> +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts new file mode 100644 index 00000000000..a40776f26ad --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' +import type { Editor } from '@tiptap/react' +import { useMarkdownMentions } from './use-markdown-mentions' + +/** + * Wires an editor's `@` mention menu to its workspace data: gates the menu on a workspace scope, + * lazily fetches the data on the first open, and feeds it into the menu's reactive store. Shared by + * every editor surface that mounts the mention extension (the file editor and the modal field). + */ +export function useEditorMentions(editor: Editor | null, workspaceId: string | undefined): void { + const [active, setActive] = useState(false) + const items = useMarkdownMentions(workspaceId, { enabled: active }) + + useEffect(() => { + if (!editor) return + const hasWorkspace = Boolean(workspaceId) + editor.storage.mention.enabled = hasWorkspace + editor.storage.mention.onOpen = hasWorkspace ? () => setActive(true) : null + return () => { + editor.storage.mention.onOpen = null + } + }, [editor, workspaceId]) + + useEffect(() => { + editor?.storage.mention.store.set(items) + }, [editor, items]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts new file mode 100644 index 00000000000..d75ed04456c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -0,0 +1,99 @@ +import { useMemo } from 'react' +import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' +import { listIntegrations } from '@/blocks/integration-matcher' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useSkills } from '@/hooks/queries/skills' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import type { MentionItem } from './types' + +/** + * Aggregates the workspace-scoped entities the `@` menu can reference, composing the canonical + * per-resource React Query hooks (never the chat-coupled `useAvailableResources` aggregator). All + * queries stay disabled until `enabled` flips true — the host activates it on the first `@` trigger — + * so a markdown field that never opens the menu fetches nothing. + */ +export function useMarkdownMentions( + workspaceId: string | undefined, + options: { enabled: boolean } +): MentionItem[] { + const active = options.enabled && Boolean(workspaceId) + // Pass through only when active; each hook self-gates on a falsy workspaceId. + const ws = active ? workspaceId : undefined + const wsStr = ws ?? '' + + const files = useWorkspaceFiles(wsStr, 'active', { enabled: active }) + const folders = useWorkspaceFileFolders(wsStr, 'active') + const tables = useTablesList(ws, 'active') + const knowledgeBases = useKnowledgeBasesQuery(ws, { enabled: active }) + const workflows = useWorkflows(ws) + const skills = useSkills(wsStr) + + // The integration registry is static — materialize it once rather than on every resource refetch. + const integrationItems = useMemo(() => { + if (!active) return [] + return listIntegrations().map((integration) => ({ + kind: 'integration', + id: integration.blockType, + label: integration.name, + group: 'Integrations', + icon: integration.icon, + })) + }, [active]) + + return useMemo(() => { + if (!active) return [] + const items: MentionItem[] = [] + + for (const file of files.data ?? []) + items.push({ kind: 'file', id: file.id, label: file.name, group: 'Files', icon: File }) + for (const folder of folders.data ?? []) + items.push({ + kind: 'folder', + id: folder.id, + label: folder.name, + group: 'Folders', + icon: Folder, + }) + for (const table of tables.data ?? []) + items.push({ kind: 'table', id: table.id, label: table.name, group: 'Tables', icon: Table }) + for (const kb of knowledgeBases.data ?? []) + items.push({ + kind: 'knowledge', + id: kb.id, + label: kb.name, + group: 'Knowledge bases', + icon: Database, + }) + for (const workflow of workflows.data ?? []) + items.push({ + kind: 'workflow', + id: workflow.id, + label: workflow.name, + group: 'Workflows', + icon: Workflow, + }) + for (const skill of skills.data ?? []) + items.push({ + kind: 'skill', + id: skill.id, + label: skill.name, + group: 'Skills', + icon: Sparkles, + }) + items.push(...integrationItems) + + return items + }, [ + active, + files.data, + folders.data, + tables.data, + knowledgeBases.data, + workflows.data, + skills.data, + integrationItems, + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts new file mode 100644 index 00000000000..76ebf4b2acd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -0,0 +1,21 @@ +/** + * Shared chrome for the editor's keyboard-driven suggestion popups — the `/` slash-command menu and + * the `@` mention menu. Single source of truth so the two read identically; never re-derive these + * class strings per consumer. + */ + +/** The floating panel: bordered card with the enter animation. */ +export const SUGGESTION_SURFACE_CLASS = + 'min-w-[220px] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none' + +/** A scrollable list body (hidden scrollbar), added alongside {@link SUGGESTION_SURFACE_CLASS}. */ +export const SUGGESTION_SCROLL_CLASS = + 'max-h-[330px] scroll-py-1.5 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' + +/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */ +export const SUGGESTION_ITEM_CLASS = + 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' + +/** A group heading above a run of rows. */ +export const SUGGESTION_GROUP_LABEL_CLASS = + 'px-2 pt-1.5 pb-1 font-medium text-[var(--text-muted)] text-micro uppercase tracking-wide' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts new file mode 100644 index 00000000000..66b27bf61c1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts @@ -0,0 +1,99 @@ +import type { ForwardRefExoticComponent, PropsWithoutRef, RefAttributes } from 'react' +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' +import { ReactRenderer } from '@tiptap/react' +import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion' + +/** The imperative handle every suggestion list exposes so the popup can forward arrow/enter keys to it. */ +export interface SuggestionListHandle { + onKeyDown: (props: { event: KeyboardEvent }) => boolean +} + +type AnySuggestionProps = SuggestionProps + +function positionPopup(element: HTMLElement, getRect: AnySuggestionProps['clientRect']) { + const rect = getRect?.() + if (!rect) return + const virtualEl = { getBoundingClientRect: () => rect } + computePosition(virtualEl, element, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })], + }).then(({ x, y }) => { + if (!element.isConnected) return + element.style.left = `${x}px` + element.style.top = `${y}px` + }) +} + +interface SuggestionPopupConfig { + /** The React list component, mounted via `ReactRenderer` into a detached, floating body element. */ + component: ForwardRefExoticComponent & RefAttributes> + /** Maps the live suggestion props to the list component's props. */ + mapProps: (props: AnySuggestionProps) => P + /** Called once when the popup opens, before mount — e.g. to lazily start data fetching. */ + onOpen?: (props: AnySuggestionProps) => void +} + +/** + * Builds the `render` lifecycle for a `@tiptap/suggestion` popup: mounts a React list into a fixed, + * floating-ui-positioned body element, repositions on update/scroll, forwards keys to the list's + * imperative handle, and tears everything down on exit / Escape / editor-destroy. Shared by the `/` + * slash command and the `@` mention menu so the popup mechanics live in exactly one place. + */ +export function createSuggestionPopupRenderer( + config: SuggestionPopupConfig +): NonNullable { + return () => { + let component: ReactRenderer | null = null + let popup: HTMLElement | null = null + let boundEditor: AnySuggestionProps['editor'] | null = null + let stopAutoUpdate: (() => void) | null = null + + const teardown = () => { + stopAutoUpdate?.() + stopAutoUpdate = null + boundEditor?.off('destroy', teardown) + boundEditor = null + popup?.remove() + component?.destroy() + popup = null + component = null + } + + return { + onStart: (props) => { + teardown() + config.onOpen?.(props) + component = new ReactRenderer(config.component, { + // ReactRenderer types its props option loosely; the component still enforces P. + props: config.mapProps(props) as Record, + editor: props.editor, + }) + popup = document.createElement('div') + popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' + popup.appendChild(component.element) + document.body.appendChild(popup) + boundEditor = props.editor + boundEditor.on('destroy', teardown) + const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() } + const surface = popup + stopAutoUpdate = autoUpdate(reference, surface, () => + positionPopup(surface, props.clientRect) + ) + }, + onUpdate: (props) => { + component?.updateProps(config.mapProps(props) as Record) + if (popup) positionPopup(popup, props.clientRect) + }, + onKeyDown: (props) => { + if (props.event.isComposing) return false + if (props.event.key === 'Escape') { + teardown() + return true + } + return component?.ref?.onKeyDown(props) ?? false + }, + onExit: teardown, + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts new file mode 100644 index 00000000000..f50fdd98647 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts @@ -0,0 +1,23 @@ +import { + applyFrontmatter, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' +import { serializeMarkdownBody } from './markdown-parse' +import { isRoundTripSafe } from './round-trip-safety' + +/** + * The canonical form the rich editor serializes a document to (`*`→`-` bullets, padded table cells, + * `_em_`→`*em*`, …). A markdown file authored elsewhere (e.g. the former Monaco editor) is rarely in + * this form, so the editor's first mount-time re-serialization would otherwise read as an unsaved edit + * and falsely mark the file dirty. Normalizing the dirty-check baseline to this exact form on open + * neutralizes that — verified to match the live editor's own serialization byte-for-byte. + * + * Round-trip-UNSAFE content (raw HTML, footnotes, >128KB) is returned untouched: those files open + * read-only and must display their original bytes, never a lossy re-serialization. + */ +export function normalizeMarkdownContent(raw: string): string { + if (!isRoundTripSafe(raw)) return raw + const { frontmatter, body } = splitFrontmatter(raw) + return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(serializeMarkdownBody(body))) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 6abc89a6010..b580b6c4628 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -322,3 +322,17 @@ height: 0; pointer-events: none; } + +/* + * Field variant (modal embed): match the surrounding chip fields' typography exactly — + * body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the + * lighter document `--text-subtle`), so the editor reads as one of the form's fields. + */ +.rich-markdown-field-prose { + font-size: 14px; + line-height: 22px; +} + +.rich-markdown-field-prose p.is-editor-empty:first-child::before { + color: var(--text-muted); +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index d4d10637113..3d1b18619a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -21,8 +21,10 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' +import { parseSimHref, simLinkPath, useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' +import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' import '@/components/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -86,6 +88,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onDirtyChange, onSaveStatusChange, saveRef, + normalizeBaseline: normalizeMarkdownContent, }) if (isContentLoading) return @@ -258,6 +261,14 @@ export function LoadedRichMarkdownEditor({ }) return true } + // A `@`-mention link (`sim:/`) navigates to the referenced resource in-app. + if (href.startsWith('sim:')) { + const parsed = parseSimHref(href) + const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) + if (!path) return false + routerRef.current.push(path) + return true + } const normalized = normalizeLinkHref(href) if (!normalized) return false // A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab. @@ -311,6 +322,8 @@ export function LoadedRichMarkdownEditor({ } }, [editor]) + useEditorMentions(editor, workspaceId) + const wasStreamingRef = useRef(streamingAtMountRef.current) const pendingStreamBodyRef = useRef(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx new file mode 100644 index 00000000000..92af6e2a49d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { JSONContent } from '@tiptap/core' +import { EditorContent, useEditor } from '@tiptap/react' +import { chipFieldSurfaceClass } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { createMarkdownEditorExtensions } from './extensions' +import { + applyFrontmatter, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' +import { parseMarkdownToDoc } from './markdown-parse' +import { useEditorMentions } from './mention' +import { EditorBubbleMenu } from './menus/bubble-menu' +import { LinkHoverCard } from './menus/link-hover-card' +import '@/components/emcn/components/code/code.css' +import './rich-markdown-editor.css' + +interface RichMarkdownFieldProps { + /** Current markdown value. Seeds the editor once on mount; external changes only apply while {@link isStreaming}. */ + value: string + /** Fires with the serialized markdown on every local edit. */ + onChange: (markdown: string) => void + placeholder?: string + /** Renders the editor read-only (e.g. while saving). */ + disabled?: boolean + /** True while `value` is being pushed in externally (AI generation) — the editor turns read-only and mirrors each update. */ + isStreaming?: boolean + autoFocus?: boolean + /** Min height of the scroll box in px. */ + minHeight?: number + /** Max height of the scroll box in px before it scrolls. */ + maxHeight?: number + /** Swaps the border to the error token (the message itself is rendered by the surrounding field). */ + error?: boolean + /** Enables the `@` mention menu scoped to this workspace. Omit to disable mentions. */ + workspaceId?: string + /** + * Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste + * (e.g. a full document the host destructures elsewhere); `false` to fall through to normal + * markdown paste. + */ + onPasteText?: (text: string) => boolean +} + +/** + * A controlled, string-valued WYSIWYG markdown editor for modal fields — the file-less sibling of + * {@link RichMarkdownEditor}. It reuses the same TipTap extensions, parser, and menus but owns no file + * loading, autosave, or image upload. Drop it inside a `ChipModalField type='custom'`. + */ +export function RichMarkdownField({ + value, + onChange, + placeholder = "Write something, or press '/' for commands…", + disabled = false, + isStreaming = false, + autoFocus = false, + minHeight = 140, + maxHeight = 360, + error = false, + workspaceId, + onPasteText, +}: RichMarkdownFieldProps) { + const containerRef = useRef(null) + + // Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. + // Split once at mount — the refs and the seed doc all derive from the initial value. + const [initialSplit] = useState(() => splitFrontmatter(value)) + const frontmatterRef = useRef(initialSplit.frontmatter) + // The body last reflected into the editor — updated on local edits and on each streamed sync. + const lastSyncedBodyRef = useRef(initialSplit.body) + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + const onPasteTextRef = useRef(onPasteText) + onPasteTextRef.current = onPasteText + + // TipTap extensions are stateful — build them once per mount so each field gets its own placeholder. + const [extensions] = useState(() => createMarkdownEditorExtensions({ placeholder })) + const [initialContent] = useState(() => parseMarkdownToDoc(initialSplit.body)) + + const editor = useEditor({ + extensions, + editable: !disabled && !isStreaming, + autofocus: autoFocus ? 'end' : false, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + content: initialContent, + editorProps: { + attributes: { class: 'rich-markdown-prose rich-markdown-field-prose' }, + handlePaste: (_view, event) => { + const handler = onPasteTextRef.current + if (!handler) return false + const text = event.clipboardData?.getData('text/plain') + if (!text) return false + return handler(text) + }, + }, + onUpdate: ({ editor }) => { + const md = postProcessSerializedMarkdown(editor.getMarkdown()) + lastSyncedBodyRef.current = md + onChangeRef.current(applyFrontmatter(frontmatterRef.current, md)) + }, + }) + + // Mirror an externally-driven value (AI generation) into the editor, then settle to editable. + const wasStreamingRef = useRef(isStreaming) + useEffect(() => { + if (!editor) return + const { frontmatter, body } = splitFrontmatter(value) + frontmatterRef.current = frontmatter + + if (isStreaming) { + wasStreamingRef.current = true + if (editor.isEditable) editor.setEditable(false) + if (body === lastSyncedBodyRef.current) return + lastSyncedBodyRef.current = body + const el = containerRef.current + const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 60 : false + editor.commands.setContent(parseMarkdownToDoc(body), { + contentType: 'json', + emitUpdate: false, + }) + if (el && pinnedToBottom) el.scrollTop = el.scrollHeight + return + } + + // Settle: re-seed the freshly-generated body once, then restore editability. + if (wasStreamingRef.current) { + wasStreamingRef.current = false + if (body !== lastSyncedBodyRef.current) { + lastSyncedBodyRef.current = body + editor.commands.setContent(parseMarkdownToDoc(body), { + contentType: 'json', + emitUpdate: false, + }) + } + } + if (editor.isEditable !== !disabled) editor.setEditable(!disabled) + }, [editor, value, isStreaming, disabled]) + + useEditorMentions(editor, workspaceId) + + return ( +
+ {editor && } + {editor && } + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts index 5244548bbfe..cc25c8dfa7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts @@ -162,6 +162,13 @@ describe('editor markdown round-trip', () => { }) } + // The `@`-mention link scheme must survive the schema, or the mention is silently stripped to + // plain text (which idempotency above can't detect). See the `sim` protocol in extensions.ts. + it('preserves a @-mention sim: link', () => { + const input = 'see [my-skill](sim:skill/abc123) and [Spec](sim:file/xyz-789)' + expect(roundTrip(input)).toBe(input) + }) + it('preserves frontmatter through a full round-trip', () => { const input = '---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n\ntext' const out = roundTrip(input) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index d9f10a7e8db..ad80e4ff74a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -1,5 +1,11 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { cn } from '@/lib/core/utils/cn' +import { + SUGGESTION_GROUP_LABEL_CLASS, + SUGGESTION_ITEM_CLASS, + SUGGESTION_SCROLL_CLASS, + SUGGESTION_SURFACE_CLASS, +} from '../menus/suggestion-menu-chrome' import type { SlashCommandItem } from './commands' export interface SlashCommandListHandle { @@ -11,12 +17,6 @@ interface SlashCommandListProps { command: (item: SlashCommandItem) => void } -const SURFACE_CLASS = - 'min-w-[220px] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none' - -const ITEM_CLASS = - 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' - /** * The `/` command popup. Mirrors the Chat composer's skills menu — same item chrome, * grouped headings, and arrow/enter keyboard navigation — so the two feel identical. @@ -70,7 +70,7 @@ export const SlashCommandList = forwardRef +

No results

) @@ -81,17 +81,11 @@ export const SlashCommandList = forwardRef {groups.map((group) => (
- {group.items.map(({ item, index }) => { @@ -104,7 +98,10 @@ export const SlashCommandList = forwardRef setActiveIndex(index)} onMouseDown={(event) => { event.preventDefault() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index 1ceff0c9eed..737317a3f08 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -1,15 +1,13 @@ -import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' -import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' -import { ReactRenderer } from '@tiptap/react' -import Suggestion, { type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion' +import Suggestion from '@tiptap/suggestion' +import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' import { filterSlashCommands, type SlashCommandContext, type SlashCommandItem, type SlashCommandStorage, } from './commands' -import { SlashCommandList, type SlashCommandListHandle } from './slash-command-list' +import { SlashCommandList } from './slash-command-list' declare module '@tiptap/core' { interface Storage { @@ -17,72 +15,6 @@ declare module '@tiptap/core' { } } -type SlashSuggestionProps = SuggestionProps - -function positionPopup(element: HTMLElement, getRect: SlashSuggestionProps['clientRect']) { - const rect = getRect?.() - if (!rect) return - const virtualEl = { getBoundingClientRect: () => rect } - computePosition(virtualEl, element, { - placement: 'bottom-start', - strategy: 'fixed', - middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })], - }).then(({ x, y }) => { - if (!element.isConnected) return - element.style.left = `${x}px` - element.style.top = `${y}px` - }) -} - -function renderSlashSuggestion(): ReturnType> { - let component: ReactRenderer | null = null - let popup: HTMLElement | null = null - let boundEditor: Editor | null = null - let stopAutoUpdate: (() => void) | null = null - - const teardown = () => { - stopAutoUpdate?.() - stopAutoUpdate = null - boundEditor?.off('destroy', teardown) - boundEditor = null - popup?.remove() - component?.destroy() - popup = null - component = null - } - - return { - onStart: (props) => { - teardown() - component = new ReactRenderer(SlashCommandList, { props, editor: props.editor }) - popup = document.createElement('div') - popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' - popup.appendChild(component.element) - document.body.appendChild(popup) - boundEditor = props.editor - boundEditor.on('destroy', teardown) - const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() } - const surface = popup - stopAutoUpdate = autoUpdate(reference, surface, () => - positionPopup(surface, props.clientRect) - ) - }, - onUpdate: (props) => { - component?.updateProps(props) - if (popup) positionPopup(popup, props.clientRect) - }, - onKeyDown: (props) => { - if (props.event.isComposing) return false - if (props.event.key === 'Escape') { - teardown() - return true - } - return component?.ref?.onKeyDown(props) ?? false - }, - onExit: teardown, - } -} - /** * Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. @@ -119,7 +51,13 @@ export const SlashCommand = Extension.create, SlashCommand const ctx: SlashCommandContext = { editor, range } props.run(ctx) }, - render: renderSlashSuggestion, + render: createSuggestionPopupRenderer({ + component: SlashCommandList, + mapProps: (props) => ({ + items: props.items as SlashCommandItem[], + command: props.command, + }), + }), }), ] }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index d50da96819a..245476d2d92 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useReducer, useRef } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useUpdateWorkspaceFileContent, @@ -36,6 +36,13 @@ interface UseEditableFileContentOptions { onDirtyChange?: (isDirty: boolean) => void onSaveStatusChange?: (status: SaveStatus) => void saveRef?: React.MutableRefObject<(() => Promise) | null> + /** + * Optional transform applied to the fetched content before it becomes the editor's baseline. A + * surface whose editor re-serializes its content to a canonical form (the rich markdown editor) + * passes its normalizer so an already-canonical file never reads as dirty on open. Applied only to + * the at-rest baseline, never while an agent stream is in flight. Stable reference required. + */ + normalizeBaseline?: (raw: string) => string } interface EditableFileContent { @@ -108,6 +115,7 @@ export function useEditableFileContent({ onDirtyChange, onSaveStatusChange, saveRef, + normalizeBaseline, }: UseEditableFileContentOptions): EditableFileContent { const onDirtyChangeRef = useRef(onDirtyChange) const onSaveStatusChangeRef = useRef(onSaveStatusChange) @@ -125,6 +133,24 @@ export function useEditableFileContent({ GENERATED_SOURCE_FILE_TYPES.has(file.type) ) + /** + * Latches once this mount has ever streamed (agent edit). A mount that streams keeps the raw fetched + * value as its baseline for its whole life, so normalization can never perturb the stream-reconcile + * comparisons in {@link syncTextEditorContentState}. A pure at-rest open never latches and normalizes + * freely. Set during render (not an effect) so it is observed before the baseline is derived. + */ + const everStreamedRef = useRef(false) + if (streamingContent !== undefined || isAgentEditing) everStreamedRef.current = true + + // Re-derived only when the fetched content changes (never on a stream-flag flip), so the dirty + // baseline stays stable through a post-stream reconcile. + const baselineContent = useMemo(() => { + if (fetchedContent === undefined || !normalizeBaseline || everStreamedRef.current) { + return fetchedContent + } + return normalizeBaseline(fetchedContent) + }, [fetchedContent, normalizeBaseline]) + const updateContent = useUpdateWorkspaceFileContent() const updateContentRef = useRef(updateContent) updateContentRef.current = updateContent @@ -138,7 +164,7 @@ export function useEditableFileContent({ markSavedContent, } = useFileContentState({ canReconcileToFetchedContent: file.key.length > 0, - fetchedContent, + fetchedContent: baselineContent, streamingContent, }) diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx index 7b2323c13ad..6f3a332db51 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx @@ -1,9 +1,8 @@ 'use client' -import type { ChangeEvent } from 'react' import { useCallback, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' -import { Chip, ChipInput, ChipModalField, ChipTextarea, Loader } from '@/components/emcn' +import { Chip, ChipInput, ChipModalField, Loader } from '@/components/emcn' import { requestJson } from '@/lib/api/client/request' import { importSkillContract } from '@/lib/api/contracts' import { @@ -31,15 +30,35 @@ function isAcceptedFile(file: File): boolean { } export function SkillImport({ onImport }: SkillImportProps) { - const [fileState, setFileState] = useState('idle') - const [fileError, setFileError] = useState('') - const [githubUrl, setGithubUrl] = useState('') const [githubState, setGithubState] = useState('idle') const [githubError, setGithubError] = useState('') - const [pasteContent, setPasteContent] = useState('') - const [pasteError, setPasteError] = useState('') + const [fileState, setFileState] = useState('idle') + const [fileError, setFileError] = useState('') + + const handleGithubImport = useCallback(async () => { + const trimmed = githubUrl.trim() + if (!trimmed) { + setGithubError('Please enter a GitHub URL') + setGithubState('error') + return + } + + setGithubState('loading') + setGithubError('') + + try { + const data = await requestJson(importSkillContract, { body: { url: trimmed } }) + const parsed = parseSkillMarkdown(data.content) + setGithubState('idle') + onImport(parsed) + } catch (err) { + const message = getErrorMessage(err, 'Failed to import from GitHub') + setGithubError(message) + setGithubState('error') + } + }, [githubUrl, onImport]) const processFile = useCallback( async (file: File) => { @@ -86,56 +105,8 @@ export function SkillImport({ onImport }: SkillImportProps) { [processFile] ) - const handleGithubImport = useCallback(async () => { - const trimmed = githubUrl.trim() - if (!trimmed) { - setGithubError('Please enter a GitHub URL') - setGithubState('error') - return - } - - setGithubState('loading') - setGithubError('') - - try { - const data = await requestJson(importSkillContract, { body: { url: trimmed } }) - const parsed = parseSkillMarkdown(data.content) - setGithubState('idle') - onImport(parsed) - } catch (err) { - const message = getErrorMessage(err, 'Failed to import from GitHub') - setGithubError(message) - setGithubState('error') - } - }, [githubUrl, onImport]) - - const handlePasteImport = useCallback(() => { - const trimmed = pasteContent.trim() - if (!trimmed) { - setPasteError('Please paste some content first') - return - } - - setPasteError('') - const parsed = parseSkillMarkdown(trimmed) - onImport(parsed) - }, [pasteContent, onImport]) - return (
- - - -
- -
- ) => { - setPasteContent(e.target.value) - if (pasteError) setPasteError('') - }} - resizable - className='min-h-[120px]' - /> -
- - Import - -
-
-
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index ac9b21a6f02..8ef0e7e9edf 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -1,6 +1,7 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' +import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import { ChipModal, @@ -10,11 +11,25 @@ import { ChipModalFooter, ChipModalHeader, ChipModalTabs, + chipFieldSurfaceClass, } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import' +import { parseSkillMarkdown } from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SkillDefinition } from '@/hooks/queries/skills' import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills' +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + interface SkillModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -50,6 +65,9 @@ export function SkillModal({ const [name, setName] = useState('') const [description, setDescription] = useState('') const [content, setContent] = useState('') + // Bumped to remount the rich Content editor when content is set programmatically (a pasted + // SKILL.md is destructured into the fields) — the editor otherwise only seeds on mount. + const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) const [saving, setSaving] = useState(false) const [activeTab, setActiveTab] = useState('create') @@ -126,16 +144,27 @@ export function SkillModal({ } } - const handleImport = useCallback( - (data: { name: string; description: string; content: string }) => { - setName(data.name) - setDescription(data.description) - setContent(data.content) - setErrors({}) - setActiveTab('create') - }, - [] - ) + const applyImportedSkill = (data: { name: string; description: string; content: string }) => { + setName(data.name) + setDescription(data.description) + setContent(data.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + } + + const handleImport = (data: { name: string; description: string; content: string }) => { + applyImportedSkill(data) + setActiveTab('create') + } + + /** Pasting a full SKILL.md (YAML frontmatter) into Content destructures it into the fields. */ + const handleContentPaste = (text: string): boolean => { + if (!text.trimStart().startsWith('---')) return false + const parsed = parseSkillMarkdown(text) + if (!parsed.name) return false + applyImportedSkill(parsed) + return true + } const isEditing = !!initialValues const readOnly = !!initialValues?.readOnly @@ -197,21 +226,23 @@ export function SkillModal({ error={errors.description} /> - { - setContent(value) - if (errors.content || errors.general) - setErrors((prev) => ({ ...prev, content: undefined, general: undefined })) - }} - placeholder='Skill instructions in markdown...' - minHeight={200} - resizable - required - error={errors.content} - /> + + { + setContent(value) + if (errors.content || errors.general) + setErrors((prev) => ({ ...prev, content: undefined, general: undefined })) + }} + placeholder='Skill instructions in markdown...' + minHeight={200} + disabled={readOnly || saving} + error={!!errors.content} + workspaceId={workspaceId} + onPasteText={handleContentPaste} + /> + {errors.general} 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 bfbf7b0cc73..bf31df3ec69 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 @@ -1,6 +1,8 @@ 'use client' import { useRef, useState } from 'react' +import dynamic from 'next/dynamic' +import { useParams } from 'next/navigation' import { ChipConfirmModal, ChipModal, @@ -9,12 +11,27 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + chipFieldSurfaceClass, } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { useGenerateVersionDescription, useUpdateDeploymentVersion, } from '@/hooks/queries/deployments' +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + +const MAX_DESCRIPTION_LENGTH = 2000 + interface VersionDescriptionModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -32,6 +49,9 @@ export function VersionDescriptionModal({ versionName, currentDescription, }: VersionDescriptionModalProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + const initialDescriptionRef = useRef(currentDescription || '') const [description, setDescription] = useState(initialDescriptionRef.current) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) @@ -41,6 +61,7 @@ export function VersionDescriptionModal({ const hasChanges = description.trim() !== initialDescriptionRef.current.trim() const isGenerating = generateMutation.isPending + const isTooLong = description.length > MAX_DESCRIPTION_LENGTH const handleCloseAttempt = () => { if (updateMutation.isPending || isGenerating) { @@ -70,7 +91,7 @@ export function VersionDescriptionModal({ } const handleSave = () => { - if (!workflowId) return + if (!workflowId || isTooLong) return updateMutation.mutate( { @@ -96,21 +117,26 @@ export function VersionDescriptionModal({ handleCloseAttempt()}>Version Description {currentDescription ? 'Edit the' : 'Add a'} description for{' '} {versionName} } - value={description} - onChange={setDescription} - placeholder='Describe the changes in this deployment version...' - maxLength={2000} - minHeight={120} - disabled={isGenerating} - hint={`${description.length}/2000`} - /> + hint={`${description.length}/${MAX_DESCRIPTION_LENGTH}`} + > + MAX_DESCRIPTION_LENGTH} + workspaceId={workspaceId} + /> + {updateMutation.error?.message || generateMutation.error?.message} @@ -128,7 +154,7 @@ export function VersionDescriptionModal({ primaryAction={{ label: updateMutation.isPending ? 'Saving...' : 'Save', onClick: handleSave, - disabled: updateMutation.isPending || isGenerating || !hasChanges, + disabled: updateMutation.isPending || isGenerating || !hasChanges || isTooLong, }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index eee59c4ece7..7e13011f960 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -15,6 +15,7 @@ import { } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label' import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments' import { VersionDescriptionModal } from './version-description-modal' @@ -72,7 +73,7 @@ export function Versions({ const handleStartRename = (version: number, currentName: string | null | undefined) => { setOpenDropdown(null) setEditingVersion(version) - setEditValue(currentName || `v${version}`) + setEditValue(currentName ?? '') } const handleSaveRename = (version: number) => { @@ -83,7 +84,7 @@ export function Versions({ } const currentVersion = versions.find((v) => v.version === version) - const currentName = currentVersion?.name || `v${version}` + const currentName = currentVersion?.name ?? '' if (editValue.trim() === currentName) { setEditingVersion(null) @@ -250,6 +251,7 @@ export function Versions({ }} onClick={(e) => e.stopPropagation()} onBlur={() => handleSaveRename(v.version)} + placeholder={`v${v.version}`} className={cn( 'h-auto w-full border-0 bg-transparent p-0 font-medium text-[var(--text-primary)] text-caption leading-5 shadow-none outline-none focus:outline-none focus-visible:ring-0' )} @@ -261,11 +263,16 @@ export function Versions({ spellCheck='false' /> ) : ( - - {v.name || `v${v.version}`} - {v.isActive && (live)} + + + v{v.version} + + {v.name && {v.name}} + {v.isActive && ( + (live) + )} {isSelected && ( - (selected) + (selected) )} )} @@ -364,9 +371,10 @@ export function Versions({ onOpenChange={(open) => !open && setDescriptionModalVersion(null)} workflowId={workflowId} version={descriptionModalVersionData.version} - versionName={ - descriptionModalVersionData.name || `v${descriptionModalVersionData.version}` - } + versionName={formatVersionLabel( + descriptionModalVersionData.version, + descriptionModalVersionData.name + )} currentDescription={descriptionModalVersionData.description} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts new file mode 100644 index 00000000000..4cde3ae9a79 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts @@ -0,0 +1,8 @@ +/** + * Formats a deployment version label so the numeric version is always a short, stable reference. + * Unnamed versions read as `v3`; named versions keep the number alongside the custom name (`v3 · My name`), + * so a long, truncated name never hides the shorthand a user can refer to. + */ +export function formatVersionLabel(version: number, name?: string | null): string { + return name ? `v${version} · ${name}` : `v${version}` +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 374115cc8fc..5e7a258b8cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -25,6 +25,7 @@ import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/w import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { Versions } from './components' +import { formatVersionLabel } from './format-version-label' const logger = createLogger('GeneralDeploy') @@ -198,7 +199,7 @@ export function GeneralDeploy({
@@ -210,7 +211,9 @@ export function GeneralDeploy({ > Live - {selectedVersionInfo?.name || `v${selectedVersion}`} + {selectedVersionInfo + ? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name) + : `v${selectedVersion}`}
@@ -281,7 +284,12 @@ export function GeneralDeploy({ title='Load Deployment' text={[ 'Are you sure you want to load ', - { text: versionToLoadInfo?.name || `v${versionToLoad?.version}`, bold: true }, + { + text: versionToLoadInfo + ? formatVersionLabel(versionToLoadInfo.version, versionToLoadInfo.name) + : `v${versionToLoad?.version}`, + bold: true, + }, '? ', { text: 'This will replace your current workflow with the deployed version.', @@ -302,7 +310,12 @@ export function GeneralDeploy({ title='Promote to live' text={[ 'Are you sure you want to promote ', - { text: versionToPromoteInfo?.name || `v${versionToPromote?.version}`, bold: true }, + { + text: versionToPromoteInfo + ? formatVersionLabel(versionToPromoteInfo.version, versionToPromoteInfo.name) + : `v${versionToPromote?.version}`, + bold: true, + }, ' to live? This version will become the active deployment and serve all API requests.', ]} confirm={{ @@ -318,7 +331,7 @@ export function GeneralDeploy({ {previewMode === 'selected' && selectedVersionInfo - ? selectedVersionInfo.name || `v${selectedVersion}` + ? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name) : 'Live Workflow'} From 9e8824d1d0117a0920e33bcae747366d7b48e570 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:01:44 -0700 Subject: [PATCH 02/32] fix(rich-editor): address review feedback on modal field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RichMarkdownField reports the original value when the doc matches its canonical form, so a non-canonical input never reads as a false unsaved change (skill + version description modals) - Add sim: mention link navigation (Cmd/Ctrl-click) to the modal field - versions: keep the v{n} fallback as the rename guard/seed so re-submitting the displayed token is a no-op (no redundant "v3 · v3"); document the clear-name no-op - Clarify the lazy query-gating comment in useMarkdownMentions --- .../mention/use-markdown-mentions.ts | 4 ++- .../rich-markdown-field.tsx | 27 +++++++++++++++++-- .../general/components/versions.tsx | 7 ++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index d75ed04456c..175b940679a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -20,7 +20,9 @@ export function useMarkdownMentions( options: { enabled: boolean } ): MentionItem[] { const active = options.enabled && Boolean(workspaceId) - // Pass through only when active; each hook self-gates on a falsy workspaceId. + // When inactive, `ws` is undefined and `wsStr` is '' so every query stays disabled until the first + // `@`: the hooks that expose an `enabled` option get it explicitly; the rest (which take no options) + // self-gate internally on the falsy workspaceId — both empty string and undefined read as disabled. const ws = active ? workspaceId : undefined const wsStr = ws ?? '' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 92af6e2a49d..0da2a492708 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' +import { useRouter } from 'next/navigation' import { chipFieldSurfaceClass } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { createMarkdownEditorExtensions } from './extensions' @@ -12,9 +13,10 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' -import { useEditorMentions } from './mention' +import { parseSimHref, simLinkPath, useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' +import { normalizeMarkdownContent } from './normalize-content' import '@/components/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -64,6 +66,9 @@ export function RichMarkdownField({ onPasteText, }: RichMarkdownFieldProps) { const containerRef = useRef(null) + const router = useRouter() + const routerRef = useRef(router) + routerRef.current = router // Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. // Split once at mount — the refs and the seed doc all derive from the initial value. @@ -76,6 +81,12 @@ export function RichMarkdownField({ const onPasteTextRef = useRef(onPasteText) onPasteTextRef.current = onPasteText + // The original value verbatim, plus its canonical serialization. The editor only ever emits canonical + // markdown, so an already-non-canonical input would re-serialize on mount and read as an unsaved edit; + // reporting the original when the doc matches its canonical form keeps the field clean until a real edit. + const initialValueRef = useRef(value) + const [canonicalSeed] = useState(() => normalizeMarkdownContent(value)) + // TipTap extensions are stateful — build them once per mount so each field gets its own placeholder. const [extensions] = useState(() => createMarkdownEditorExtensions({ placeholder })) const [initialContent] = useState(() => parseMarkdownToDoc(initialSplit.body)) @@ -96,11 +107,23 @@ export function RichMarkdownField({ if (!text) return false return handler(text) }, + handleClick: (view, _pos, event) => { + // Cmd/Ctrl-click an `@`-mention link to navigate to the resource (a plain click places the caret). + const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') + if (!href?.startsWith('sim:') || !workspaceId) return false + if (view.editable && !(event.metaKey || event.ctrlKey)) return false + const parsed = parseSimHref(href) + const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) + if (!path) return false + routerRef.current.push(path) + return true + }, }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md - onChangeRef.current(applyFrontmatter(frontmatterRef.current, md)) + const serialized = applyFrontmatter(frontmatterRef.current, md) + onChangeRef.current(serialized === canonicalSeed ? initialValueRef.current : serialized) }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 7e13011f960..055d3b0ae48 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -73,18 +73,20 @@ export function Versions({ const handleStartRename = (version: number, currentName: string | null | undefined) => { setOpenDropdown(null) setEditingVersion(version) - setEditValue(currentName ?? '') + setEditValue(currentName || `v${version}`) } const handleSaveRename = (version: number) => { if (renameMutation.isPending) return + // Clearing the name is a no-op — the version number is always the canonical reference. if (!workflowId || !editValue.trim()) { setEditingVersion(null) return } const currentVersion = versions.find((v) => v.version === version) - const currentName = currentVersion?.name ?? '' + // Compare against the `v{n}` fallback so re-submitting the displayed token saves no redundant name. + const currentName = currentVersion?.name || `v${version}` if (editValue.trim() === currentName) { setEditingVersion(null) @@ -251,7 +253,6 @@ export function Versions({ }} onClick={(e) => e.stopPropagation()} onBlur={() => handleSaveRename(v.version)} - placeholder={`v${v.version}`} className={cn( 'h-auto w-full border-0 bg-transparent p-0 font-medium text-[var(--text-primary)] text-caption leading-5 shadow-none outline-none focus:outline-none focus-visible:ring-0' )} From 8b42e2f8cce58caaaea0ecc1f6ff7b2505219cce Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:21:22 -0700 Subject: [PATCH 03/32] fix(skills): re-seed Content editor when initialValues changes Bump the field's remount key in the reset guard so the seed-once rich editor re-seeds when content is reset from a changed initialValues (same skill id keeps the React key otherwise stable), keeping the editor and saved value in sync. --- .../skills/components/skill-modal/skill-modal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index 8ef0e7e9edf..39dffab5ee4 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -65,8 +65,8 @@ export function SkillModal({ const [name, setName] = useState('') const [description, setDescription] = useState('') const [content, setContent] = useState('') - // Bumped to remount the rich Content editor when content is set programmatically (a pasted - // SKILL.md is destructured into the fields) — the editor otherwise only seeds on mount. + // Bumped to remount the seed-once rich Content editor whenever `content` is set programmatically — + // a reset from a changed `initialValues` or a destructured SKILL.md paste — so the editor re-seeds. const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) const [saving, setSaving] = useState(false) @@ -80,6 +80,9 @@ export function SkillModal({ setContent(initialValues?.content ?? '') setErrors({}) setActiveTab('create') + // Remount the seed-once Content editor so it re-seeds from the reset value (an `initialValues` + // change for the same skill keeps the React key otherwise stable). + setContentSeed((seed) => seed + 1) } if (open !== prevOpen) setPrevOpen(open) if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues) From fc91a8133d77739f5930d0100f1641fc284289f8 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 16:17:50 -0700 Subject: [PATCH 04/32] feat(rich-editor): render mentions as icon chips + menu/limit polish - Render @ mentions as an inline chip node (entity icon + label) instead of a blue link; still serializes to the portable [label](sim:kind/id) markdown so it round-trips and stays agent-readable (shared mentionIcon resolver) - Cap the mention/slash menu height + width and scroll it, matching the chat menu - Give the version description editor more height; lift the 2000-char limit to a high anti-abuse cap (client + contract) and drop the visible counter --- .../rich-markdown-editor/extensions.ts | 3 +- .../rich-markdown-editor/mention/index.ts | 1 + .../mention/mention-icon.ts | 26 ++++ .../mention/mention-node.test.ts | 43 +++++++ .../mention/mention-node.tsx | 121 ++++++++++++++++++ .../rich-markdown-editor/mention/mention.ts | 4 +- .../mention/use-markdown-mentions.ts | 28 ++-- .../menus/suggestion-menu-chrome.ts | 12 +- .../rich-markdown-editor.tsx | 10 +- .../rich-markdown-field.tsx | 17 +-- .../components/version-description-modal.tsx | 7 +- apps/sim/lib/api/contracts/deployments.ts | 7 +- 12 files changed, 228 insertions(+), 51 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index defb1332247..fa8917937a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -17,7 +17,7 @@ import { MarkdownImage, ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownPaste } from './markdown-paste' -import { Mention, SIM_LINK_SCHEME } from './mention' +import { MarkdownMention, Mention, MentionChip, SIM_LINK_SCHEME } from './mention' import { SlashCommand } from './slash-command/slash-command' /** @@ -94,6 +94,7 @@ export function createMarkdownContentExtensions({ InlineCode, codeBlock, (nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }), + nodeViews ? MentionChip : MarkdownMention, TaskList, TaskItem.configure({ nested: true }), PipeSafeTable.configure({ resizable: true }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index 361f282333d..d5350cab508 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,4 +1,5 @@ export { Mention, type MentionStorage } from './mention' +export { MarkdownMention, MentionChip } from './mention-node' export { parseSimHref, SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' export type { MentionItem, MentionKind } from './types' export { useEditorMentions } from './use-editor-mentions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts new file mode 100644 index 00000000000..9537e54ec50 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts @@ -0,0 +1,26 @@ +import type { ComponentType } from 'react' +import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' +import { getBlock } from '@/blocks/registry' +import type { MentionKind } from './types' + +/** Icon component shape both the lucide kind-icons and the brand block icons satisfy. */ +export type MentionIcon = ComponentType<{ className?: string }> + +const KIND_ICONS: Record, MentionIcon> = { + file: File, + folder: Folder, + table: Table, + knowledge: Database, + workflow: Workflow, + skill: Sparkles, +} + +/** + * Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by + * blockType, which is the mention `id`); every other kind uses a lucide category icon. Shared by the + * menu rows and the inserted chip so both render the same icon. + */ +export function mentionIcon(kind: MentionKind, id: string): MentionIcon | undefined { + if (kind === 'integration') return getBlock(id)?.icon as MentionIcon | undefined + return KIND_ICONS[kind] +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts new file mode 100644 index 00000000000..cb5797d7d47 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment jsdom + * + * The `@`-mention is stored as a portable `[label](sim:/)` markdown link but parses into a + * dedicated `mention` node (rendered live as a chip). These guard that the parse → node → serialize + * cycle is lossless, so the chat-portable wire format and the chip rendering stay in sync. + */ +import type { JSONContent } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { parseMarkdownToDoc, serializeMarkdownBody } from '../markdown-parse' + +function findMention(node: JSONContent): JSONContent | null { + if (node.type === 'mention') return node + for (const child of node.content ?? []) { + const found = findMention(child) + if (found) return found + } + return null +} + +describe('mention node round-trip', () => { + it('parses a sim: link into a mention node with kind/id/label', () => { + const doc = parseMarkdownToDoc('See [Airweave](sim:integration/airweave) here') + const mention = findMention(doc) + expect(mention).not.toBeNull() + expect(mention?.attrs).toEqual({ kind: 'integration', id: 'airweave', label: 'Airweave' }) + }) + + it('serializes a mention node back to the portable sim: link', () => { + for (const input of [ + 'See [Airweave](sim:integration/airweave) here', + '[my-skill](sim:skill/abc-123)', + 'a [Spec.md](sim:file/xyz_789) b', + ]) { + expect(serializeMarkdownBody(input).trim()).toBe(input) + } + }) + + it('leaves a normal http link as a link, not a mention', () => { + const doc = parseMarkdownToDoc('[Sim](https://sim.ai)') + expect(findMention(doc)).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx new file mode 100644 index 00000000000..ac942dc43b3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -0,0 +1,121 @@ +import type { MouseEvent } from 'react' +import type { JSONContent, MarkdownToken } from '@tiptap/core' +import { Node } from '@tiptap/core' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { useParams, useRouter } from 'next/navigation' +import { cn } from '@/lib/core/utils/cn' +import { mentionIcon } from './mention-icon' +import { simLinkPath, toSimHref } from './sim-link' +import type { MentionKind } from './types' + +interface MentionAttrs { + kind: MentionKind + id: string + label: string +} + +/** The markdown form of a mention — the chat's portable `[label](sim:/)` link. */ +const MENTION_MD_RE = /^\[([^\]]+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/ + +/** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */ +interface MentionTokenFields { + label?: string + kind?: string + id?: string +} + +/** + * Inline atom node for an `@`-mention. Renders (live) as a chip with the entity's icon, but serializes + * to the portable `[label](sim:/)` markdown link — so the saved content is identical to a + * plain link (agent-readable, round-trips through the chat's `chip-clipboard-codec`) while the editor + * shows it as a chip rather than a blue link. Shared by the headless round-trip path (no node view) + * and the live {@link MentionChip}, mirroring the image node's split. + */ +export const MarkdownMention = Node.create({ + name: 'mention', + inline: true, + group: 'inline', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + kind: { default: '' }, + id: { default: '' }, + label: { default: '' }, + } + }, + + parseHTML() { + return [ + { + tag: 'span[data-mention]', + getAttrs: (element) => ({ + kind: element.getAttribute('data-kind') ?? '', + id: element.getAttribute('data-id') ?? '', + label: element.textContent ?? '', + }), + }, + ] + }, + + renderHTML({ node }) { + const { kind, id, label } = node.attrs as MentionAttrs + return ['span', { 'data-mention': '', 'data-kind': kind, 'data-id': id }, label] + }, + + markdownTokenizer: { + name: 'mention', + level: 'inline' as const, + start: (src: string) => src.indexOf('['), + tokenize: (src: string): (MentionTokenFields & { type: string; raw: string }) | undefined => { + const match = MENTION_MD_RE.exec(src) + if (!match) return undefined + return { type: 'mention', raw: match[0], label: match[1], kind: match[2], id: match[3] } + }, + }, + parseMarkdown: (token: MarkdownToken): JSONContent => { + const { kind, id, label } = token as MentionTokenFields + return { type: 'mention', attrs: { kind: kind ?? '', id: id ?? '', label: label ?? '' } } + }, + renderMarkdown: (node: JSONContent): string => { + const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs + return `[${label}](${toSimHref(kind, id)})` + }, +}) + +const CHIP_CLASS = + 'mx-px inline-flex items-center gap-1 rounded-[4px] bg-[var(--surface-4)] px-1 align-middle font-medium text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[14px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' + +/** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */ +function MentionChipView({ node }: ReactNodeViewProps) { + const router = useRouter() + const params = useParams() + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + const { kind, id, label } = node.attrs as MentionAttrs + const Icon = mentionIcon(kind, id) + + const handleClick = (event: MouseEvent) => { + if (!(event.metaKey || event.ctrlKey) || !workspaceId) return + const path = simLinkPath(workspaceId, kind, id) + if (!path) return + event.preventDefault() + router.push(path) + } + + return ( + + {Icon && } + {label} + + ) +} + +/** Live mention node with the chip view; same schema + markdown output as the headless one. */ +export const MentionChip = MarkdownMention.extend({ + addNodeView() { + return ReactNodeViewRenderer(MentionChipView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index 498a3212197..db35b0dfc01 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -4,7 +4,6 @@ import Suggestion from '@tiptap/suggestion' import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' import { MentionList } from './mention-list' import { createMentionStore, type MentionStore } from './mention-store' -import { toSimHref } from './sim-link' import type { MentionItem } from './types' /** Distinct from the `/` slash command's default `suggestion` key — two plugins can't share one key. */ @@ -63,13 +62,12 @@ export const Mention = Extension.create, MentionStorage>({ // Items are sourced reactively from the store inside MentionList; this only gates the plugin. items: () => [], command: ({ editor, range, props }) => { - const href = toSimHref(props.kind, props.id) editor .chain() .focus() .deleteRange(range) .insertContent([ - { type: 'text', text: props.label, marks: [{ type: 'link', attrs: { href } }] }, + { type: 'mention', attrs: { kind: props.kind, id: props.id, label: props.label } }, { type: 'text', text: ' ' }, ]) .run() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index 175b940679a..023faacc43c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -1,5 +1,4 @@ import { useMemo } from 'react' -import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' import { listIntegrations } from '@/blocks/integration-matcher' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useSkills } from '@/hooks/queries/skills' @@ -7,6 +6,7 @@ import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { mentionIcon } from './mention-icon' import type { MentionItem } from './types' /** @@ -41,7 +41,7 @@ export function useMarkdownMentions( id: integration.blockType, label: integration.name, group: 'Integrations', - icon: integration.icon, + icon: mentionIcon('integration', integration.blockType), })) }, [active]) @@ -50,24 +50,36 @@ export function useMarkdownMentions( const items: MentionItem[] = [] for (const file of files.data ?? []) - items.push({ kind: 'file', id: file.id, label: file.name, group: 'Files', icon: File }) + items.push({ + kind: 'file', + id: file.id, + label: file.name, + group: 'Files', + icon: mentionIcon('file', file.id), + }) for (const folder of folders.data ?? []) items.push({ kind: 'folder', id: folder.id, label: folder.name, group: 'Folders', - icon: Folder, + icon: mentionIcon('folder', folder.id), }) for (const table of tables.data ?? []) - items.push({ kind: 'table', id: table.id, label: table.name, group: 'Tables', icon: Table }) + items.push({ + kind: 'table', + id: table.id, + label: table.name, + group: 'Tables', + icon: mentionIcon('table', table.id), + }) for (const kb of knowledgeBases.data ?? []) items.push({ kind: 'knowledge', id: kb.id, label: kb.name, group: 'Knowledge bases', - icon: Database, + icon: mentionIcon('knowledge', kb.id), }) for (const workflow of workflows.data ?? []) items.push({ @@ -75,7 +87,7 @@ export function useMarkdownMentions( id: workflow.id, label: workflow.name, group: 'Workflows', - icon: Workflow, + icon: mentionIcon('workflow', workflow.id), }) for (const skill of skills.data ?? []) items.push({ @@ -83,7 +95,7 @@ export function useMarkdownMentions( id: skill.id, label: skill.name, group: 'Skills', - icon: Sparkles, + icon: mentionIcon('skill', skill.id), }) items.push(...integrationItems) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts index 76ebf4b2acd..1d24fbee07b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -4,13 +4,15 @@ * class strings per consumer. */ -/** The floating panel: bordered card with the enter animation. */ +/** The floating panel: bordered card with the enter animation, width-capped like the chat mention menu. */ export const SUGGESTION_SURFACE_CLASS = - 'min-w-[220px] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none' + 'min-w-[220px] max-w-[min(300px,calc(100vw-32px))] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none' -/** A scrollable list body (hidden scrollbar), added alongside {@link SUGGESTION_SURFACE_CLASS}. */ -export const SUGGESTION_SCROLL_CLASS = - 'max-h-[330px] scroll-py-1.5 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' +/** + * A scrollable list body, added alongside {@link SUGGESTION_SURFACE_CLASS}. Caps the height and scrolls + * — matching the chat composer's `@` menu — so a long workspace list never overflows its container. + */ +export const SUGGESTION_SCROLL_CLASS = 'max-h-[240px] scroll-py-1.5 overflow-y-auto overscroll-none' /** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */ export const SUGGESTION_ITEM_CLASS = diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 3d1b18619a7..ba64b9ddda2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -21,7 +21,7 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' -import { parseSimHref, simLinkPath, useEditorMentions } from './mention' +import { useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' import { normalizeMarkdownContent } from './normalize-content' @@ -261,14 +261,6 @@ export function LoadedRichMarkdownEditor({ }) return true } - // A `@`-mention link (`sim:/`) navigates to the referenced resource in-app. - if (href.startsWith('sim:')) { - const parsed = parseSimHref(href) - const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) - if (!path) return false - routerRef.current.push(path) - return true - } const normalized = normalizeLinkHref(href) if (!normalized) return false // A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 0da2a492708..bb497f7500a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' -import { useRouter } from 'next/navigation' import { chipFieldSurfaceClass } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { createMarkdownEditorExtensions } from './extensions' @@ -13,7 +12,7 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' -import { parseSimHref, simLinkPath, useEditorMentions } from './mention' +import { useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' import { normalizeMarkdownContent } from './normalize-content' @@ -66,9 +65,6 @@ export function RichMarkdownField({ onPasteText, }: RichMarkdownFieldProps) { const containerRef = useRef(null) - const router = useRouter() - const routerRef = useRef(router) - routerRef.current = router // Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. // Split once at mount — the refs and the seed doc all derive from the initial value. @@ -107,17 +103,6 @@ export function RichMarkdownField({ if (!text) return false return handler(text) }, - handleClick: (view, _pos, event) => { - // Cmd/Ctrl-click an `@`-mention link to navigate to the resource (a plain click places the caret). - const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') - if (!href?.startsWith('sim:') || !workspaceId) return false - if (view.editable && !(event.metaKey || event.ctrlKey)) return false - const parsed = parseSimHref(href) - const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) - if (!path) return false - routerRef.current.push(path) - return true - }, }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) 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 bf31df3ec69..7d22794ab9a 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 @@ -30,7 +30,8 @@ const RichMarkdownField = dynamic( } ) -const MAX_DESCRIPTION_LENGTH = 2000 +/** A high cap that only guards against abuse — no visible counter; normal descriptions never reach it. */ +const MAX_DESCRIPTION_LENGTH = 50_000 interface VersionDescriptionModalProps { open: boolean @@ -124,13 +125,13 @@ export function VersionDescriptionModal({ {versionName} } - hint={`${description.length}/${MAX_DESCRIPTION_LENGTH}`} > MAX_DESCRIPTION_LENGTH} diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index 18ae97ecd15..1b9cd57db72 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -36,12 +36,7 @@ export const deploymentVersionMetadataFieldsSchema = z.object({ .min(1, 'Name cannot be empty') .max(100, 'Name must be 100 characters or less') .optional(), - description: z - .string() - .trim() - .max(2000, 'Description must be 2000 characters or less') - .nullable() - .optional(), + description: z.string().trim().max(50_000, 'Description is too long').nullable().optional(), }) export const updateDeploymentVersionMetadataBodySchema = deploymentVersionMetadataFieldsSchema.refine( From 64b603ba8733a870f033632f767944a30970fdfe Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 16:49:27 -0700 Subject: [PATCH 05/32] fix(rich-editor): make suggestion menus scrollable inside modals - Mount the slash/@ menu popup inside the host dialog (when present) instead of document.body: Radix's scroll-lock blocks wheel events outside the dialog subtree, so a body-level popup couldn't scroll in a modal. position:fixed keeps it viewport-positioned (the modal centers via flex, no transform) so it isn't clipped - Fix the invalid max-w arbitrary value (calc needs spaces) that left the menu uncapped - Match the version-description editor's dynamic-import loading height to the field so the modal doesn't grow when the chunk loads --- .../rich-markdown-editor/menus/suggestion-menu-chrome.ts | 2 +- .../rich-markdown-editor/menus/suggestion-popup.ts | 6 +++++- .../general/components/version-description-modal.tsx | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts index 1d24fbee07b..c468042a18d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -6,7 +6,7 @@ /** The floating panel: bordered card with the enter animation, width-capped like the chat mention menu. */ export const SUGGESTION_SURFACE_CLASS = - 'min-w-[220px] max-w-[min(300px,calc(100vw-32px))] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none' + 'min-w-[220px] max-w-[320px] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none' /** * A scrollable list body, added alongside {@link SUGGESTION_SURFACE_CLASS}. Caps the height and scrolls diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts index 66b27bf61c1..7457ef517e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts @@ -72,7 +72,11 @@ export function createSuggestionPopupRenderer popup = document.createElement('div') popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' popup.appendChild(component.element) - document.body.appendChild(popup) + // Mount inside the host dialog when the editor is in a modal: Radix's scroll-lock blocks wheel + // events outside the dialog subtree, so a body-level popup can't be scrolled. `position: fixed` + // keeps it viewport-positioned (the modal centers via flex, no transform) so it isn't clipped. + const host = props.editor.view.dom.closest('[role="dialog"]') ?? document.body + host.appendChild(popup) boundEditor = props.editor boundEditor.on('destroy', teardown) const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() } 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 7d22794ab9a..00896a0a1a0 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 @@ -26,7 +26,7 @@ const RichMarkdownField = dynamic( ).then((m) => m.RichMarkdownField), { ssr: false, - loading: () =>
, + loading: () =>
, } ) From 3654241ad63a944abc72b822a2dcce3681e7a2d5 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 18:51:30 -0700 Subject: [PATCH 06/32] fix(rich-editor): escape bracketed mention labels + disable images in field editors - Escape/unescape `[`/`]` in mention labels so an entity named e.g. `data[1].csv` round-trips into a chip instead of degrading to a plain link - Hide the `/Image` command where image upload isn't wired (the skill + version description field editors), so images can't be inserted there; the file viewer keeps image support --- .../mention/mention-node.test.ts | 8 ++++++ .../mention/mention-node.tsx | 25 ++++++++++++++++--- .../slash-command/commands.test.ts | 10 ++++++++ .../slash-command/commands.ts | 16 ++++++++---- .../slash-command/slash-command.ts | 7 +++++- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts index cb5797d7d47..ec6447ff88b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts @@ -36,6 +36,14 @@ describe('mention node round-trip', () => { } }) + it('round-trips a label containing brackets (e.g. a bracketed file name) as a chip', () => { + const input = '[data\\[1\\].csv](sim:file/abc)' + const doc = parseMarkdownToDoc(input) + const mention = findMention(doc) + expect(mention?.attrs).toEqual({ kind: 'file', id: 'abc', label: 'data[1].csv' }) + expect(serializeMarkdownBody(input).trim()).toBe(input) + }) + it('leaves a normal http link as a link, not a mention', () => { const doc = parseMarkdownToDoc('[Sim](https://sim.ai)') expect(findMention(doc)).toBeNull() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index ac942dc43b3..248bb4a938d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -15,8 +15,22 @@ interface MentionAttrs { label: string } -/** The markdown form of a mention — the chat's portable `[label](sim:/)` link. */ -const MENTION_MD_RE = /^\[([^\]]+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/ +/** + * The markdown form of a mention — the chat's portable `[label](sim:/)` link. The label + * group accepts backslash-escaped characters so a label containing `[`/`]` (e.g. a file named + * `data[1].csv`) still round-trips into a chip instead of degrading to a plain link. + */ +const MENTION_MD_RE = /^\[((?:\\.|[^\]\\])+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/ + +/** Escape `\`, `[`, `]` in a mention label so brackets in entity names can't break the link syntax. */ +function escapeLabel(label: string): string { + return label.replace(/[\\[\]]/g, '\\$&') +} + +/** Inverse of {@link escapeLabel}, applied when parsing a mention back from markdown. */ +function unescapeLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, '$1') +} /** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */ interface MentionTokenFields { @@ -78,11 +92,14 @@ export const MarkdownMention = Node.create({ }, parseMarkdown: (token: MarkdownToken): JSONContent => { const { kind, id, label } = token as MentionTokenFields - return { type: 'mention', attrs: { kind: kind ?? '', id: id ?? '', label: label ?? '' } } + return { + type: 'mention', + attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') }, + } }, renderMarkdown: (node: JSONContent): string => { const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs - return `[${label}](${toSimHref(kind, id)})` + return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts index 634c85e3a1b..90b74adf7ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts @@ -32,6 +32,16 @@ describe('filterSlashCommands', () => { it('returns empty for no match', () => { expect(filterSlashCommands('zzz')).toEqual([]) }) + + it('drops the Image command when image insertion is disallowed', () => { + expect(filterSlashCommands('', { allowImages: false }).map((c) => c.title)).not.toContain( + 'Image' + ) + expect(filterSlashCommands('image', { allowImages: false })).toEqual([]) + expect(filterSlashCommands('image', { allowImages: true }).map((c) => c.title)).toContain( + 'Image' + ) + }) }) describe('SLASH_COMMANDS registry', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts index a3bdd960bc8..9399e91f4e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -156,13 +156,19 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [ ] /** - * Filters commands by a case-insensitive match against title or aliases. Order is - * preserved so the menu stays stable as the query narrows. + * Filters commands by a case-insensitive match against title or aliases. Order is preserved so the + * menu stays stable as the query narrows. The Image command is dropped when image insertion isn't + * available (`allowImages: false`) — e.g. the modal field editors, which have no upload affordance. */ -export function filterSlashCommands(query: string): SlashCommandItem[] { +export function filterSlashCommands( + query: string, + options?: { allowImages?: boolean } +): SlashCommandItem[] { + const allowImages = options?.allowImages ?? true + const available = allowImages ? SLASH_COMMANDS : SLASH_COMMANDS.filter((c) => c.title !== 'Image') const q = query.trim().toLowerCase() - if (!q) return [...SLASH_COMMANDS] - return SLASH_COMMANDS.filter( + if (!q) return [...available] + return available.filter( (command) => command.title.toLowerCase().includes(q) || command.aliases.some((alias) => alias.includes(q)) ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index 737317a3f08..6b885a7da36 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -46,7 +46,12 @@ export const SlashCommand = Extension.create, SlashCommand if ($from.parentOffset === 0) return true return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset)) }, - items: ({ query }) => filterSlashCommands(query), + // The Image command is offered only where image upload is wired (the file viewer); the modal + // field editors never set `insertImage`, so `@`-style image insertion is hidden there. + items: ({ editor, query }) => + filterSlashCommands(query, { + allowImages: editor.storage.slashCommand.insertImage != null, + }), command: ({ editor, range, props }) => { const ctx: SlashCommandContext = { editor, range } props.run(ctx) From d64ca90538405be713ac2cf0b1bae7d22db8b577 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 19:19:23 -0700 Subject: [PATCH 07/32] fix(rich-editor): keep suggestion keyboard nav working after async items load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion plugin captures the list's onKeyDown handle via ReactRenderer.ref once at mount. The mention list's items arrive asynchronously from the workspace store, so the captured handle closed over an empty `flat` and returned false for arrow/enter — letting the editor move the caret instead of navigating the menu. Read live values through a ref so the mount-time handle always sees current items/activeIndex. Hardened the slash list the same way. --- .../mention/mention-list.tsx | 51 +++++++++++-------- .../slash-command/slash-command-list.tsx | 50 ++++++++++-------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 2e892ff4565..7814fa6ce9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -90,26 +90,37 @@ export const MentionList = forwardRef(funct ?.scrollIntoView({ block: 'nearest' }) }, [activeIndex]) - useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }) => { - if (flat.length === 0) return false - if (event.key === 'ArrowUp') { - setActiveIndex((i) => (i + flat.length - 1) % flat.length) - return true - } - if (event.key === 'ArrowDown') { - setActiveIndex((i) => (i + 1) % flat.length) - return true - } - if (event.key === 'Enter') { - const item = flat[activeIndex] - if (!item) return false - command(item) - return true - } - return false - }, - })) + // The suggestion plugin captures this handle via `ReactRenderer.ref` once at mount, so it must read + // live values rather than close over them — otherwise keyboard nav uses the initial (empty) `flat` + // from before the async workspace data landed, and arrow keys fall through to the editor. + const latest = useRef({ flat, activeIndex, command }) + latest.current = { flat, activeIndex, command } + + useImperativeHandle( + ref, + () => ({ + onKeyDown: ({ event }) => { + const { flat, activeIndex, command } = latest.current + if (flat.length === 0) return false + if (event.key === 'ArrowUp') { + setActiveIndex((i) => (i + flat.length - 1) % flat.length) + return true + } + if (event.key === 'ArrowDown') { + setActiveIndex((i) => (i + 1) % flat.length) + return true + } + if (event.key === 'Enter') { + const item = flat[activeIndex] + if (!item) return false + command(item) + return true + } + return false + }, + }), + [] + ) if (flat.length === 0) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index ad80e4ff74a..2d5abdfb9e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -37,26 +37,36 @@ export const SlashCommandList = forwardRef ({ - onKeyDown: ({ event }) => { - if (items.length === 0) return false - if (event.key === 'ArrowUp') { - setActiveIndex((i) => (i + items.length - 1) % items.length) - return true - } - if (event.key === 'ArrowDown') { - setActiveIndex((i) => (i + 1) % items.length) - return true - } - if (event.key === 'Enter') { - const item = items[activeIndex] - if (!item) return false - command(item) - return true - } - return false - }, - })) + // Read live values: the suggestion plugin captures this handle via `ReactRenderer.ref` at mount, + // so closing over `items`/`activeIndex` would make Enter act on the mount-time snapshot. + const latest = useRef({ items, activeIndex, command }) + latest.current = { items, activeIndex, command } + + useImperativeHandle( + ref, + () => ({ + onKeyDown: ({ event }) => { + const { items, activeIndex, command } = latest.current + if (items.length === 0) return false + if (event.key === 'ArrowUp') { + setActiveIndex((i) => (i + items.length - 1) % items.length) + return true + } + if (event.key === 'ArrowDown') { + setActiveIndex((i) => (i + 1) % items.length) + return true + } + if (event.key === 'Enter') { + const item = items[activeIndex] + if (!item) return false + command(item) + return true + } + return false + }, + }), + [] + ) const groups = useMemo(() => { const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = [] From 93fb8d9ef522c526732ce7a5b9b103c114b97df1 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 19:32:34 -0700 Subject: [PATCH 08/32] test(rich-editor): cover suggestion keyboard nav through ReactRenderer; drop inline comments Adds a test that drives the real ReactRenderer path the suggestion plugin uses: the captured onKeyDown handle returns false while the store is empty and true once async workspace items land, and arrow+enter select the right item. Removes the explanatory inline comments from the two imperative handles. --- .../mention/mention-list.test.tsx | 96 +++++++++++++++++++ .../mention/mention-list.tsx | 3 - .../slash-command/slash-command-list.tsx | 2 - 3 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx new file mode 100644 index 00000000000..19ef5504a84 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -0,0 +1,96 @@ +/** + * @vitest-environment jsdom + * + * Guards the `@` menu's keyboard navigation against the async-data race: the suggestion plugin grabs + * the list's `onKeyDown` handle once, but workspace items arrive later via the store. The handle must + * read live values so arrow/enter work after the data lands (otherwise keys fall through to the editor). + * The second test drives the real `ReactRenderer` path the suggestion plugin actually uses. + */ +import { act, createRef } from 'react' +import { Editor } from '@tiptap/core' +import { EditorContent, ReactRenderer } from '@tiptap/react' +import { File } from 'lucide-react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from '../extensions' +import { MentionList, type MentionListHandle } from './mention-list' +import { createMentionStore } from './mention-store' +import type { MentionItem } from './types' + +const items: MentionItem[] = [ + { kind: 'file', id: 'a', label: 'Alpha', group: 'Files', icon: File }, + { kind: 'file', id: 'b', label: 'Beta', group: 'Files', icon: File }, +] + +const arrowDown = { event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) } +const enter = { event: new KeyboardEvent('keydown', { key: 'Enter' }) } + +describe('MentionList keyboard nav', () => { + let container: HTMLElement + let root: ReturnType + + beforeEach(async () => { + // jsdom implements neither — both are exercised by scroll-into-view and ProseMirror. + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) + const { createRoot } = await import('react-dom/client') + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('navigates with arrows + inserts on enter once async items have loaded', () => { + const ref = createRef() + const command = vi.fn() + const store = createMentionStore() + + // Menu opens before the workspace data resolves — the store is still empty. + act(() => { + root.render() + }) + expect(ref.current?.onKeyDown(arrowDown)).toBe(false) + + // Async data lands; the captured handle must now see the items and intercept the keys. + act(() => store.set(items)) + + let handled: boolean | undefined + act(() => { + handled = ref.current?.onKeyDown(arrowDown) + }) + expect(handled).toBe(true) + + act(() => { + ref.current?.onKeyDown(enter) + }) + expect(command).toHaveBeenCalledWith(items[1]) + }) + + it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => { + const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) + act(() => { + root.render() + }) + + const command = vi.fn() + const store = createMentionStore() + const renderer = new ReactRenderer(MentionList, { + editor, + props: { query: '', command, store }, + }) + // Let the portal mount so ReactRenderer captures the imperative handle. + await act(async () => {}) + + expect(renderer.ref).not.toBeNull() + expect(renderer.ref?.onKeyDown(arrowDown)).toBe(false) + + act(() => store.set(items)) + expect(renderer.ref?.onKeyDown(arrowDown)).toBe(true) + + renderer.destroy() + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 7814fa6ce9c..97cedfdc0a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -90,9 +90,6 @@ export const MentionList = forwardRef(funct ?.scrollIntoView({ block: 'nearest' }) }, [activeIndex]) - // The suggestion plugin captures this handle via `ReactRenderer.ref` once at mount, so it must read - // live values rather than close over them — otherwise keyboard nav uses the initial (empty) `flat` - // from before the async workspace data landed, and arrow keys fall through to the editor. const latest = useRef({ flat, activeIndex, command }) latest.current = { flat, activeIndex, command } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index 2d5abdfb9e8..2243038b2f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -37,8 +37,6 @@ export const SlashCommandList = forwardRef Date: Thu, 25 Jun 2026 19:51:16 -0700 Subject: [PATCH 09/32] fix(rich-editor): suggestion menus keep arrow keys when a divider is adjacent The leaf-selection keymap (ArrowUp/Down selects an adjacent divider/image) runs at priority 1000, above the suggestion plugins, so it stole ArrowDown to select the next horizontal rule instead of moving the open @/ menu selection. It now yields while a mention or slash menu is active, detected via the plugins' exported keys. --- .../rich-markdown-editor/keymap.test.ts | 60 +++++++++++++++++++ .../rich-markdown-editor/keymap.ts | 19 +++++- .../rich-markdown-editor/mention/index.ts | 2 +- .../rich-markdown-editor/mention/mention.ts | 4 +- .../slash-command/slash-command.ts | 5 ++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts new file mode 100644 index 00000000000..169a3cf360e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment jsdom + * + * The leaf-selection arrow shortcuts (ArrowUp/ArrowDown → select an adjacent divider/image) run at a + * high priority, so they must yield while a `/` or `@` suggestion menu is open — otherwise the arrow + * selects the adjacent node instead of moving the menu selection. These assert the plugin state the + * keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens. + */ +import { Editor } from '@tiptap/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from './extensions' +import { MENTION_PLUGIN_KEY } from './mention' +import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' + +function editorWith(content: string): Editor { + return new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content }) +} + +describe('suggestion-aware arrow keymap', () => { + beforeEach(() => { + // The suggestion render lifecycle uses these; jsdom lacks them. + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) + }) + + it('flags the mention menu active when `@` is typed before a divider', () => { + const editor = editorWith('


') + editor.commands.focus() + editor.commands.insertContent('@gma') + + expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(true) + editor.destroy() + }) + + it('flags the slash menu active when `/` is typed', () => { + const editor = editorWith('

') + editor.commands.focus() + editor.commands.insertContent('/') + + expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(true) + editor.destroy() + }) + + it('keeps both menus inactive on plain text', () => { + const editor = editorWith('

hello


') + editor.commands.focus() + + expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(false) + expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(false) + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index b3b75bd510a..2894ef59d38 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -1,9 +1,23 @@ import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' +import { MENTION_PLUGIN_KEY } from './mention' +import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' /** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */ const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image']) +/** + * True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so + * the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider. + */ +function isSuggestionMenuOpen(editor: Editor): boolean { + const { state } = editor + return ( + MENTION_PLUGIN_KEY.getState(state)?.active === true || + SLASH_COMMAND_PLUGIN_KEY.getState(state)?.active === true + ) +} + /** * Arrowing off the edge of a textblock toward an adjacent divider or image selects that node * (a NodeSelection), giving keyboard parity with clicking it. Without this the gap cursor swallows @@ -63,8 +77,9 @@ export const RichMarkdownKeymap = Extension.create({ if (editor.state.selection.from === from && editor.state.selection.to === to) return false return editor.commands.setTextSelection({ from, to }) }, - ArrowUp: ({ editor }) => selectAdjacentLeaf(editor, 'up'), - ArrowDown: ({ editor }) => selectAdjacentLeaf(editor, 'down'), + ArrowUp: ({ editor }) => !isSuggestionMenuOpen(editor) && selectAdjacentLeaf(editor, 'up'), + ArrowDown: ({ editor }) => + !isSuggestionMenuOpen(editor) && selectAdjacentLeaf(editor, 'down'), } }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index d5350cab508..c7c845382d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,4 +1,4 @@ -export { Mention, type MentionStorage } from './mention' +export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention' export { MarkdownMention, MentionChip } from './mention-node' export { parseSimHref, SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' export type { MentionItem, MentionKind } from './types' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index db35b0dfc01..f2b69e40799 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -6,8 +6,8 @@ import { MentionList } from './mention-list' import { createMentionStore, type MentionStore } from './mention-store' import type { MentionItem } from './types' -/** Distinct from the `/` slash command's default `suggestion` key — two plugins can't share one key. */ -const MENTION_PLUGIN_KEY = new PluginKey('mention') +/** Distinct from the `/` slash command's key — two plugins can't share one key. Exported so the keymap can detect an open menu. */ +export const MENTION_PLUGIN_KEY = new PluginKey('mention') /** * Per-editor storage for the `@` mention extension. The host component populates {@link store} with diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index 6b885a7da36..dc7186c4ea6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -1,4 +1,5 @@ import { Extension } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' import Suggestion from '@tiptap/suggestion' import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' import { @@ -15,6 +16,9 @@ declare module '@tiptap/core' { } } +/** Explicit key (distinct from the `@` mention's) so the keymap can detect an open menu. */ +export const SLASH_COMMAND_PLUGIN_KEY = new PluginKey('slashCommand') + /** * Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. @@ -30,6 +34,7 @@ export const SlashCommand = Extension.create, SlashCommand return [ Suggestion({ editor: this.editor, + pluginKey: SLASH_COMMAND_PLUGIN_KEY, char: '/', allowSpaces: false, startOfLine: false, From 7e32dcdb4b1d4957754e811a88cb6b2d15701d73 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 20:04:48 -0700 Subject: [PATCH 10/32] feat(rich-editor): Tab accepts a suggestion; unify list keyboard nav; match chip styling - Extract useSuggestionKeyboard: one hook owns the @/ menus' active-row state, scroll-into-view, and arrow/enter/tab handling (removes the duplication between the two list components) - Tab now accepts the active item like Enter, matching the chat composer - Render the mention chip like the chat input's mention token: borderless inline icon + label (no pill), 12px icon with brand color via getBareIconStyle, so the styling is consistent across surfaces --- .../mention/mention-list.test.tsx | 19 +++++ .../mention/mention-list.tsx | 61 +++------------- .../mention/mention-node.tsx | 19 +++-- .../menus/use-suggestion-keyboard.ts | 69 +++++++++++++++++++ .../slash-command/slash-command-list.tsx | 54 +++------------ 5 files changed, 124 insertions(+), 98 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx index 19ef5504a84..739e6af9cfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -23,6 +23,7 @@ const items: MentionItem[] = [ const arrowDown = { event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) } const enter = { event: new KeyboardEvent('keydown', { key: 'Enter' }) } +const tab = { event: new KeyboardEvent('keydown', { key: 'Tab' }) } describe('MentionList keyboard nav', () => { let container: HTMLElement @@ -69,6 +70,24 @@ describe('MentionList keyboard nav', () => { expect(command).toHaveBeenCalledWith(items[1]) }) + it('accepts the active item on Tab, like Enter', () => { + const ref = createRef() + const command = vi.fn() + const store = createMentionStore() + + act(() => { + root.render() + }) + act(() => store.set(items)) + + let handled: boolean | undefined + act(() => { + handled = ref.current?.onKeyDown(tab) + }) + expect(handled).toBe(true) + expect(command).toHaveBeenCalledWith(items[0]) + }) + it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => { const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) act(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 97cedfdc0a5..5049e64769d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -1,12 +1,4 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react' +import { forwardRef, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from 'react' import { cn } from '@/lib/core/utils/cn' import { SUGGESTION_GROUP_LABEL_CLASS, @@ -14,12 +6,14 @@ import { SUGGESTION_SCROLL_CLASS, SUGGESTION_SURFACE_CLASS, } from '../menus/suggestion-menu-chrome' +import { + type SuggestionKeyDownHandler, + useSuggestionKeyboard, +} from '../menus/use-suggestion-keyboard' import type { MentionStore } from './mention-store' import type { MentionItem } from './types' -export interface MentionListHandle { - onKeyDown: (props: { event: KeyboardEvent }) => boolean -} +export type MentionListHandle = SuggestionKeyDownHandler interface MentionListProps { /** The text typed after `@`, used to filter. */ @@ -54,7 +48,6 @@ export const MentionList = forwardRef(funct ref ) { const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) - const [activeIndex, setActiveIndex] = useState(0) const containerRef = useRef(null) /** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */ @@ -80,44 +73,12 @@ export const MentionList = forwardRef(funct return { flat, groups: ordered } }, [rawItems, query]) - useEffect(() => { - setActiveIndex(0) - }, [flat]) - - useEffect(() => { - containerRef.current - ?.querySelector(`[data-index="${activeIndex}"]`) - ?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex]) - - const latest = useRef({ flat, activeIndex, command }) - latest.current = { flat, activeIndex, command } - - useImperativeHandle( - ref, - () => ({ - onKeyDown: ({ event }) => { - const { flat, activeIndex, command } = latest.current - if (flat.length === 0) return false - if (event.key === 'ArrowUp') { - setActiveIndex((i) => (i + flat.length - 1) % flat.length) - return true - } - if (event.key === 'ArrowDown') { - setActiveIndex((i) => (i + 1) % flat.length) - return true - } - if (event.key === 'Enter') { - const item = flat[activeIndex] - if (!item) return false - command(item) - return true - } - return false - }, - }), - [] + const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard( + flat, + command, + containerRef ) + useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown]) if (flat.length === 0) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index 248bb4a938d..f7df2be49ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -4,7 +4,7 @@ import { Node } from '@tiptap/core' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { useParams, useRouter } from 'next/navigation' -import { cn } from '@/lib/core/utils/cn' +import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' import { mentionIcon } from './mention-icon' import { simLinkPath, toSimHref } from './sim-link' import type { MentionKind } from './types' @@ -103,8 +103,16 @@ export const MarkdownMention = Node.create({ }, }) +/** + * Mirrors the home chat input's mention rendering (the textarea mirror overlay + * in `prompt-editor.tsx`): a borderless inline icon + label that flows with the + * surrounding prose — no pill background, no padding, normal weight, body text + * color, and a 12px icon. Integration icons keep their brand color via + * {@link getBareIconStyle} (see {@link MentionChipView}); other kinds stay + * monochrome through the `--text-icon` fallback below. + */ const CHIP_CLASS = - 'mx-px inline-flex items-center gap-1 rounded-[4px] bg-[var(--surface-4)] px-1 align-middle font-medium text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[14px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' + 'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' /** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */ function MentionChipView({ node }: ReactNodeViewProps) { @@ -112,7 +120,8 @@ function MentionChipView({ node }: ReactNodeViewProps) { const params = useParams() const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined const { kind, id, label } = node.attrs as MentionAttrs - const Icon = mentionIcon(kind, id) + const Icon = mentionIcon(kind, id) as StyleableIcon | undefined + const iconStyle = Icon ? getBareIconStyle(Icon) : undefined const handleClick = (event: MouseEvent) => { if (!(event.metaKey || event.ctrlKey) || !workspaceId) return @@ -123,8 +132,8 @@ function MentionChipView({ node }: ReactNodeViewProps) { } return ( - - {Icon && } + + {Icon && } {label} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts new file mode 100644 index 00000000000..9b456c45811 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts @@ -0,0 +1,69 @@ +import { + type Dispatch, + type RefObject, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +/** The imperative `onKeyDown` every suggestion list forwards from the popup. */ +export interface SuggestionKeyDownHandler { + onKeyDown: (props: { event: KeyboardEvent }) => boolean +} + +interface SuggestionKeyboard extends SuggestionKeyDownHandler { + activeIndex: number + setActiveIndex: Dispatch> +} + +/** + * Shared arrow/enter/tab navigation for the `/` and `@` suggestion lists. Owns the active-row state, + * resets it when the items change, scrolls the active row into view, and exposes an `onKeyDown` handle + * for the suggestion plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the + * chat composer). The handle is stable and reads live values through a ref, because the suggestion + * plugin captures it once via `ReactRenderer.ref` while the items may still be loading. + */ +export function useSuggestionKeyboard( + items: T[], + onSelect: (item: T) => void, + containerRef: RefObject +): SuggestionKeyboard { + const [activeIndex, setActiveIndex] = useState(0) + + useEffect(() => { + setActiveIndex(0) + }, [items]) + + useEffect(() => { + containerRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, containerRef]) + + const latest = useRef({ items, activeIndex, onSelect }) + latest.current = { items, activeIndex, onSelect } + + const onKeyDown = useCallback(({ event }: { event: KeyboardEvent }) => { + const { items, activeIndex, onSelect } = latest.current + if (items.length === 0) return false + if (event.key === 'ArrowUp') { + setActiveIndex((i) => (i + items.length - 1) % items.length) + return true + } + if (event.key === 'ArrowDown') { + setActiveIndex((i) => (i + 1) % items.length) + return true + } + if (event.key === 'Enter' || event.key === 'Tab') { + const item = items[activeIndex] + if (!item) return false + onSelect(item) + return true + } + return false + }, []) + + return { activeIndex, setActiveIndex, onKeyDown } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index 2243038b2f8..b7bdb66a8c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' import { cn } from '@/lib/core/utils/cn' import { SUGGESTION_GROUP_LABEL_CLASS, @@ -6,11 +6,13 @@ import { SUGGESTION_SCROLL_CLASS, SUGGESTION_SURFACE_CLASS, } from '../menus/suggestion-menu-chrome' +import { + type SuggestionKeyDownHandler, + useSuggestionKeyboard, +} from '../menus/use-suggestion-keyboard' import type { SlashCommandItem } from './commands' -export interface SlashCommandListHandle { - onKeyDown: (props: { event: KeyboardEvent }) => boolean -} +export type SlashCommandListHandle = SuggestionKeyDownHandler interface SlashCommandListProps { items: SlashCommandItem[] @@ -24,47 +26,13 @@ interface SlashCommandListProps { */ export const SlashCommandList = forwardRef( function SlashCommandList({ items, command }, ref) { - const [activeIndex, setActiveIndex] = useState(0) const containerRef = useRef(null) - - useEffect(() => { - setActiveIndex(0) - }, [items]) - - useEffect(() => { - containerRef.current - ?.querySelector(`[data-index="${activeIndex}"]`) - ?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex]) - - const latest = useRef({ items, activeIndex, command }) - latest.current = { items, activeIndex, command } - - useImperativeHandle( - ref, - () => ({ - onKeyDown: ({ event }) => { - const { items, activeIndex, command } = latest.current - if (items.length === 0) return false - if (event.key === 'ArrowUp') { - setActiveIndex((i) => (i + items.length - 1) % items.length) - return true - } - if (event.key === 'ArrowDown') { - setActiveIndex((i) => (i + 1) % items.length) - return true - } - if (event.key === 'Enter') { - const item = items[activeIndex] - if (!item) return false - command(item) - return true - } - return false - }, - }), - [] + const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard( + items, + command, + containerRef ) + useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown]) const groups = useMemo(() => { const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = [] From 70ea04ca8d7c5ebcf7b07f10e5065496089c942e Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 10:05:10 -0700 Subject: [PATCH 11/32] fix(rich-editor): harden editor edge cases found in full audit - Skill paste: only auto-destructure on a real YAML name key, so a stray `---` break or heading snippet no longer overwrites all three fields (parseSkillMarkdown reports nameFromFrontmatter) - Skill modal: reset by skill id, not object identity, so a background refetch of the open skill can't clobber in-progress edits - Field editor: claim Mod+K (inline link editor wins over global search) and swallow file drops so the browser doesn't navigate away from the modal - File editor: swallow non-image file drops (same navigation guard) - Frontmatter: a leading `---` thematic break (e.g. a changelog) is no longer mistaken for frontmatter and hidden from the editor - Mention chip: renderText emits the portable link so copying a chip into a plain-text target (e.g. chat) pastes back as a mention - Suggestion nav: clamp a one-frame stale active index on Enter/Tab --- .../rich-markdown-editor/markdown-fidelity.ts | 19 +++++++++++++++++- .../mention/mention-node.tsx | 7 +++++++ .../menus/use-suggestion-keyboard.ts | 3 ++- .../rich-markdown-editor/normalize-content.ts | 2 +- .../rich-markdown-editor.tsx | 20 +++++++++++++------ .../rich-markdown-field.tsx | 15 +++++++++++++- .../rich-markdown-editor/round-trip.test.ts | 6 ++++++ .../components/skill-modal/skill-modal.tsx | 13 ++++++++---- .../skills/components/utils.test.ts | 16 +++++++++++++++ .../[workspaceId]/skills/components/utils.ts | 6 +++++- 10 files changed, 92 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index 38c5bdf0a72..ea695e4b1e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -24,10 +24,27 @@ export function splitFrontmatter(markdown: string): SplitMarkdown { const bom = markdown.startsWith(BOM) ? BOM : '' const rest = bom ? markdown.slice(1) : markdown const match = rest.match(FRONTMATTER_REGEX) - if (!match) return { frontmatter: bom, body: rest } + if (!match || !isYamlFrontmatterBlock(match[0])) return { frontmatter: bom, body: rest } return { frontmatter: bom + match[0], body: rest.slice(match[0].length) } } +/** + * A leading `---…---` block is YAML frontmatter unless its first content line is markdown rather than + * a `key:` — so a doc that opens with a `---` thematic break (e.g. a changelog whose next `---` closes + * the regex) stays in the editor body instead of being held out-of-band and hidden. An empty block + * (`---\n---`) is still treated as (empty) frontmatter. + */ +function isYamlFrontmatterBlock(block: string): boolean { + const interior = block.replace(/^---[ \t]*\r?\n/, '') + for (const rawLine of interior.split('\n')) { + const line = rawLine.trim() + if (line === '') continue + if (line.startsWith('---')) return true // reached the closing fence — empty frontmatter + return /^[A-Za-z0-9_-]+[ \t]*:/.test(line) // first content line must be a YAML key + } + return true +} + export function applyFrontmatter(frontmatter: string, body: string): string { return frontmatter + body } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index f7df2be49ed..38059b69724 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -101,6 +101,13 @@ export const MarkdownMention = Node.create({ const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, + + // Copy/`getText` emit the portable link (an atom otherwise contributes no text), so copying a chip + // into a plain-text target — e.g. the chat composer — pastes back as a mention. + renderText: ({ node }) => { + const { kind, id, label } = node.attrs as MentionAttrs + return `[${escapeLabel(label)}](${toSimHref(kind, id)})` + }, }) /** diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts index 9b456c45811..477ee02c270 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts @@ -57,7 +57,8 @@ export function useSuggestionKeyboard( return true } if (event.key === 'Enter' || event.key === 'Tab') { - const item = items[activeIndex] + // Clamp in case a filter shrank the list this frame before the active-index reset committed. + const item = items[Math.min(activeIndex, items.length - 1)] if (!item) return false onSelect(item) return true diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts index f50fdd98647..c6f2e8f719f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts @@ -13,7 +13,7 @@ import { isRoundTripSafe } from './round-trip-safety' * and falsely mark the file dirty. Normalizing the dirty-check baseline to this exact form on open * neutralizes that — verified to match the live editor's own serialization byte-for-byte. * - * Round-trip-UNSAFE content (raw HTML, footnotes, >128KB) is returned untouched: those files open + * Round-trip-UNSAFE content (raw HTML, footnotes, >256KB) is returned untouched: those files open * read-only and must display their original bytes, never a lossy re-serialization. */ export function normalizeMarkdownContent(raw: string): string { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index ba64b9ddda2..54624ae8cad 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -140,7 +140,7 @@ interface SettledContent { verdict: boolean } -/** Locks the round-trip verdict + frontmatter once; a round-trip-unsafe doc (raw HTML, footnotes, >128KB) opens read-only. */ +/** Locks the round-trip verdict + frontmatter once; a round-trip-unsafe doc (raw HTML, footnotes, >256KB) opens read-only. */ function lockSettled(content: string): SettledContent { return { frontmatter: splitFrontmatter(content).frontmatter, verdict: isRoundTripSafe(content) } } @@ -286,11 +286,19 @@ export function LoadedRichMarkdownEditor({ handleDrop: (view, event) => { if (!view.editable) return false const images = extractImageFiles(event.dataTransfer) - if (images.length === 0) return false - event.preventDefault() - const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos - void insertImagesRef.current(images, dropPos ?? view.state.selection.from) - return true + if (images.length > 0) { + event.preventDefault() + const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos + void insertImagesRef.current(images, dropPos ?? view.state.selection.from) + return true + } + // Swallow any other file drop (e.g. a PDF) so the browser doesn't navigate away from the + // editor. Internal text drags carry no files and fall through to the default behavior. + if (event.dataTransfer?.files.length) { + event.preventDefault() + return true + } + return false }, }, onUpdate: ({ editor }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index bb497f7500a..8432d31911d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -95,7 +95,11 @@ export function RichMarkdownField({ shouldRerenderOnTransaction: false, content: initialContent, editorProps: { - attributes: { class: 'rich-markdown-prose rich-markdown-field-prose' }, + attributes: { + class: 'rich-markdown-prose rich-markdown-field-prose', + // Claim ⌘K so the bubble-menu link editor wins over the global search palette. + 'data-owned-shortcuts': 'Mod+K', + }, handlePaste: (_view, event) => { const handler = onPasteTextRef.current if (!handler) return false @@ -103,6 +107,15 @@ export function RichMarkdownField({ if (!text) return false return handler(text) }, + handleDrop: (_view, event) => { + // The field has no image upload; swallow any file drop so the browser doesn't navigate to + // the dropped file and tear down the modal. Internal text drags fall through to the default. + if (event.dataTransfer?.files.length) { + event.preventDefault() + return true + } + return false + }, }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts index cc25c8dfa7b..34db32f85c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts @@ -80,6 +80,12 @@ describe('markdown-fidelity utils', () => { expect(splitFrontmatter(md)).toEqual({ frontmatter: '', body: md }) }) + it('does not treat a leading `---` thematic break as frontmatter (keeps the top section visible)', () => { + // A changelog whose second `---` would close the regex: the `## v2.0` section must stay in body. + const md = '---\n\n## v2.0\n\nnotes\n\n---\n\n## v1.0' + expect(splitFrontmatter(md)).toEqual({ frontmatter: '', body: md }) + }) + it('holds a UTF-8 BOM out of band so frontmatter survives', () => { const input = '\uFEFF---\ntitle: x\n---\n\nbody' const { frontmatter, body } = splitFrontmatter(input) diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index 39dffab5ee4..d84c5384959 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -74,7 +74,9 @@ export function SkillModal({ const [prevOpen, setPrevOpen] = useState(false) const [prevInitialValues, setPrevInitialValues] = useState(initialValues) - if ((open && !prevOpen) || (open && initialValues !== prevInitialValues)) { + // Reset only when the *skill* changes (by id), not on a background refetch that returns a new + // object for the same open skill — otherwise an in-progress edit would be silently clobbered. + if ((open && !prevOpen) || (open && initialValues?.id !== prevInitialValues?.id)) { setName(initialValues?.name ?? '') setDescription(initialValues?.description ?? '') setContent(initialValues?.content ?? '') @@ -160,11 +162,14 @@ export function SkillModal({ setActiveTab('create') } - /** Pasting a full SKILL.md (YAML frontmatter) into Content destructures it into the fields. */ + /** + * Pasting a full SKILL.md destructures it into the fields. Gated on a real YAML `name:` key — a + * stray `---` thematic break or a heading-only snippet pastes as ordinary content instead of + * silently overwriting all three fields. + */ const handleContentPaste = (text: string): boolean => { - if (!text.trimStart().startsWith('---')) return false const parsed = parseSkillMarkdown(text) - if (!parsed.name) return false + if (!parsed.nameFromFrontmatter) return false applyImportedSkill(parsed) return true } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.test.ts index e6cc61a40d3..7be95968a34 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.test.ts @@ -21,6 +21,7 @@ describe('parseSkillMarkdown', () => { name: 'my-skill', description: 'Does something useful', content: '# Instructions\nUse this skill to do things.', + nameFromFrontmatter: true, }) }) @@ -31,6 +32,7 @@ describe('parseSkillMarkdown', () => { name: 'my-skill', description: 'A quoted description', content: 'Body', + nameFromFrontmatter: true, }) }) @@ -41,6 +43,7 @@ describe('parseSkillMarkdown', () => { name: 'api-tool', description: 'API key: required for auth', content: 'Body', + nameFromFrontmatter: true, }) }) @@ -61,6 +64,7 @@ describe('parseSkillMarkdown', () => { name: 'add-block-skill', description: 'A tool for blocks', content: '# Add Block Skill\n\nContent here.', + nameFromFrontmatter: false, }) }) @@ -71,6 +75,7 @@ describe('parseSkillMarkdown', () => { name: 'my-cool-tool', description: '', content: '# My Cool Tool\n\nSome instructions.', + nameFromFrontmatter: false, }) }) @@ -81,6 +86,7 @@ describe('parseSkillMarkdown', () => { name: '', description: '', content: 'Just some plain text without any structure.', + nameFromFrontmatter: false, }) }) @@ -89,6 +95,7 @@ describe('parseSkillMarkdown', () => { name: '', description: '', content: '', + nameFromFrontmatter: false, }) }) @@ -107,6 +114,7 @@ describe('parseSkillMarkdown', () => { name: 'solo', description: 'Just frontmatter', content: '', + nameFromFrontmatter: true, }) }) @@ -118,6 +126,14 @@ describe('parseSkillMarkdown', () => { expect(result.content).toBe(input) }) + it('flags nameFromFrontmatter only for a real YAML name key (the paste-destructure gate)', () => { + expect(parseSkillMarkdown('---\nname: real\n---\nBody').nameFromFrontmatter).toBe(true) + // A `---` thematic break + heading must NOT count — it would wrongly destructure on paste. + expect(parseSkillMarkdown('---\n# Setup Guide\nnotes').nameFromFrontmatter).toBe(false) + // A changelog whose second `---` closes the regex but has no name key. + expect(parseSkillMarkdown('---\n\n## v2.0\n\n---\n\n## v1.0').nameFromFrontmatter).toBe(false) + }) + it('trims whitespace from input', () => { const input = '\n\n ---\nname: trimmed\ndescription: yes\n---\nBody \n\n' diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts index b8a7236924a..debefcf690f 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts @@ -4,6 +4,8 @@ interface ParsedSkill { name: string description: string content: string + /** True only when `name` came from a real YAML `name:` key (not inferred from a heading). */ + nameFromFrontmatter: boolean } const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/ @@ -31,6 +33,7 @@ export function parseSkillMarkdown(raw: string): ParsedSkill { name: inferNameFromHeading(trimmed), description: '', content: trimmed, + nameFromFrontmatter: false, } } @@ -57,11 +60,12 @@ export function parseSkillMarkdown(raw: string): ParsedSkill { } } + const nameFromFrontmatter = name !== '' if (!name) { name = inferNameFromHeading(body) } - return { name, description, content: body } + return { name, description, content: body, nameFromFrontmatter } } /** From 5b2d9ef161bdbb09b7407e60ffe88cc4f115ee91 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 10:10:04 -0700 Subject: [PATCH 12/32] style(rich-editor): selected link reads as normal text, not standout blue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the standard MD-editor convention (Linear, Slack): a highlighted link takes the primary text color so the selection stays legible, instead of keeping its blue on the selection highlight. Scoped to selected links only — no effect on unselected links, regular text, the selection background, or any other surface. --- .../rich-markdown-editor/rich-markdown-editor.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index b580b6c4628..1322e700c2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -111,6 +111,14 @@ text-decoration: underline; } +/* When a link is selected, drop its blue so it reads like any other highlighted text — the standard + MD-editor convention (Linear, Slack) where the selection takes precedence over the link color. + Only the selected state is affected; an unselected link stays blue. */ +.rich-markdown-prose a::selection, +.rich-markdown-prose a ::selection { + color: var(--text-primary); +} + /* Render the gap cursor (e.g. above a leading divider) as a normal vertical caret rather than ProseMirror's default short horizontal bar, which reads as a stray underscore. */ .rich-markdown-prose .ProseMirror-gapcursor::after { From 836e2a5378521d0dc8ebc807d0c2bc730db7a000 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 10:23:16 -0700 Subject: [PATCH 13/32] fix(rich-editor): guard async suggestion + generate lifecycles against teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suggestion onStart can fire after the editor is destroyed (the update awaits items()), throwing on its now-gone view/storage — e.g. a modal closing while the menu opens. Bail when the editor is destroyed; optional-chain mention storage. This also removes the unhandled rejections the headless keymap test surfaced. - Generate version description: thread an AbortSignal so closing the modal mid-stream aborts the diff fetches + SSE read instead of streaming into a gone component. --- .../rich-markdown-editor/mention/mention.ts | 2 +- .../rich-markdown-editor/menus/suggestion-popup.ts | 4 ++++ .../general/components/version-description-modal.tsx | 11 ++++++++++- apps/sim/hooks/queries/deployments.ts | 9 +++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index f2b69e40799..a0347a31bc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -79,7 +79,7 @@ export const Mention = Extension.create, MentionStorage>({ command: props.command, store: props.editor.storage.mention.store, }), - onOpen: (props) => props.editor.storage.mention.onOpen?.(), + onOpen: (props) => props.editor.storage.mention?.onOpen?.(), }), }), ] diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts index 7457ef517e5..11500e400da 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts @@ -63,6 +63,10 @@ export function createSuggestionPopupRenderer return { onStart: (props) => { teardown() + // The suggestion update is async (it awaits `items()`), so onStart can fire after the editor + // was destroyed — e.g. a modal closed while the menu was opening. Bail before touching its + // now-unavailable view/storage. + if (props.editor.isDestroyed) return config.onOpen?.(props) component = new ReactRenderer(config.component, { // ReactRenderer types its props option loosely; the component still enforces P. 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 00896a0a1a0..1127d2e6ed3 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 @@ -1,6 +1,6 @@ 'use client' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import { @@ -81,10 +81,19 @@ export function VersionDescriptionModal({ onOpenChange(false) } + const generateAbortRef = useRef(null) + // Abort an in-flight generation if the modal unmounts mid-stream (e.g. the deploy modal closes), + // so the SSE stream stops instead of running to completion against a gone component. + useEffect(() => () => generateAbortRef.current?.abort(), []) + const handleGenerateDescription = () => { + generateAbortRef.current?.abort() + const controller = new AbortController() + generateAbortRef.current = controller generateMutation.mutate({ workflowId, version, + signal: controller.signal, onStreamChunk: (accumulated) => { setDescription(accumulated) }, diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 22f758c94bd..e53b4fa59c6 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -402,6 +402,8 @@ interface GenerateVersionDescriptionVariables { workflowId: string version: number onStreamChunk?: (accumulated: string) => void + /** Aborts the diff fetches + SSE stream when the modal unmounts mid-generation. */ + signal?: AbortSignal } const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are writing deployment version descriptions for a workflow automation platform. @@ -435,17 +437,18 @@ export function useGenerateVersionDescription() { workflowId, version, onStreamChunk, + signal, }: GenerateVersionDescriptionVariables): Promise => { const { generateWorkflowDiffSummary, formatDiffSummaryForDescriptionAsync } = await import( '@/lib/workflows/comparison/compare' ) - const currentState = await fetchDeploymentVersionState(workflowId, version) + const currentState = await fetchDeploymentVersionState(workflowId, version, signal) let previousState = null if (version > 1) { try { - previousState = await fetchDeploymentVersionState(workflowId, version - 1) + previousState = await fetchDeploymentVersionState(workflowId, version - 1, signal) } catch { // Previous version may not exist, continue without it } @@ -467,6 +470,7 @@ export function useGenerateVersionDescription() { stream: true, workflowId, }, + signal, }, { headers: { @@ -483,6 +487,7 @@ export function useGenerateVersionDescription() { const { readSSEStream } = await import('@/lib/core/utils/sse') const accumulatedContent = await readSSEStream(wandResponse.body, { onAccumulated: onStreamChunk, + signal, }) if (!accumulatedContent) { From 0efdb007244f25311e03c15680eed4baefa5c597 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 10:50:46 -0700 Subject: [PATCH 14/32] refactor(rich-editor): fold inline comments into TSDoc; display-only chip; polish - Convert the editor's inline `//` comments to TSDoc on the nearest declaration and drop the self-explanatory ones (no logic change) - Mention chip is now display-only (icon + label), matching the chat input exactly: removes select-none (so a range selection highlights the label), the cursor-pointer over-promise, and the cmd-click nav that could route away from a modal mid-edit - Don't log a deliberate generate-abort as an error - Selected strike-through text reads in the primary color so the selection is uniform --- .../rich-markdown-editor/keymap.ts | 10 ++-- .../rich-markdown-editor/markdown-fidelity.ts | 17 +++---- .../mention/mention-list.tsx | 8 +-- .../mention/mention-node.tsx | 27 +++------- .../rich-markdown-editor/mention/mention.ts | 13 +++-- .../menus/suggestion-popup.ts | 14 ++--- .../menus/use-suggestion-keyboard.ts | 4 +- .../rich-markdown-editor.css | 7 +++ .../rich-markdown-editor.tsx | 51 +++++++++++-------- .../rich-markdown-field.tsx | 27 ++++++---- .../slash-command/commands.ts | 2 - .../slash-command/slash-command.ts | 6 +-- .../components/skill-modal/skill-modal.tsx | 13 +++-- .../components/version-description-modal.tsx | 6 ++- apps/sim/hooks/queries/deployments.ts | 1 + 15 files changed, 106 insertions(+), 100 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 2894ef59d38..4057e8b7f37 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -39,9 +39,11 @@ function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean { /** * Editor-specific keyboard behavior layered on top of StarterKit's defaults: * - * - **Backspace** at the start of a heading reverts it to a paragraph; at the start of a block whose - * previous sibling is a horizontal rule it deletes the rule (ProseMirror's default `joinBackward` - * can't cross a leaf node, so without this pressing Backspace below a divider is a confusing no-op). + * - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or + * no-ops, stranding the heading style; a second Backspace then merges as usual); at the start of a + * block whose previous sibling is a horizontal rule it deletes the rule (ProseMirror's default + * `joinBackward` can't cross a leaf node, so without this pressing Backspace below a divider is a + * confusing no-op). * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the * block is already fully selected) falls through to the default whole-document select-all, the * same scoped behavior as a code editor. @@ -56,8 +58,6 @@ export const RichMarkdownKeymap = Extension.create({ Backspace: ({ editor }) => { const { selection, doc } = editor.state if (!selection.empty || selection.$from.parentOffset !== 0) return false - // At the start of a heading, revert it to a paragraph (ProseMirror's default joins or - // no-ops, stranding the heading style); a second Backspace then merges as usual. if (selection.$from.parent.type.name === 'heading') { return editor.commands.setParagraph() } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index ea695e4b1e8..e8d76cbf209 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -39,8 +39,8 @@ function isYamlFrontmatterBlock(block: string): boolean { for (const rawLine of interior.split('\n')) { const line = rawLine.trim() if (line === '') continue - if (line.startsWith('---')) return true // reached the closing fence — empty frontmatter - return /^[A-Za-z0-9_-]+[ \t]*:/.test(line) // first content line must be a YAML key + if (line.startsWith('---')) return true + return /^[A-Za-z0-9_-]+[ \t]*:/.test(line) } return true } @@ -58,10 +58,12 @@ const HOST_PORT = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i /** * Normalize a user-entered link target: prefix a bare domain with `https://` so it doesn't resolve - * as an in-app relative URL, while leaving already-qualified, relative, and protocol-relative URLs - * intact. Dangerous schemes are rejected outright rather than trusted or mangled: any `scheme:` - * without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`, `blob:`, …), and - * `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …) pass through. + * as an in-app relative URL, while leaving already-qualified, relative (`./other.md`, `../doc.md`), and + * protocol-relative URLs intact. Dangerous schemes are rejected outright rather than trusted or mangled: + * any `scheme:` without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`, + * `blob:`, …), and `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …) + * pass through. A bare `host:port` (digits after the colon) is a domain, not a scheme, so it still gets + * the `https://` prefix. */ export function normalizeLinkHref(href: string): string { const trimmed = href.trim() @@ -69,13 +71,10 @@ export function normalizeLinkHref(href: string): string { if (/^[#?]/.test(trimmed)) return trimmed if (trimmed.startsWith('//')) return `https:${trimmed}` if (trimmed.startsWith('/')) return trimmed - // Relative paths (`./other.md`, `../doc.md`) stay relative — never prefixed into `https://./…`. if (trimmed.startsWith('./') || trimmed.startsWith('../')) return trimmed if (/^(?:mailto|tel):/i.test(trimmed)) return trimmed const schemed = trimmed.match(SCHEME_URL) if (schemed) return /^file$/i.test(schemed[1]) ? '' : trimmed - // A `scheme:` without `//` (and not mailto/tel) is a script/data scheme — reject it. A bare - // host:port (digits after the colon) is a domain, not a scheme, so it falls through to https. if (HAS_SCHEME.test(trimmed) && !HOST_PORT.test(trimmed)) return '' return `https://${trimmed}` } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 5049e64769d..e92d0b54b0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -50,11 +50,13 @@ export const MentionList = forwardRef(funct const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) const containerRef = useRef(null) - /** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */ + /** + * Filtered, group-capped, flattened in category order; `index` is the flat position for nav. A single + * pass over the full set filters by label and buckets by group (capped), then reads the buckets in + * category order — avoiding a separate filter pass per group. + */ const { flat, groups } = useMemo(() => { const q = query.trim().toLowerCase() - // One pass over the full set: filter by label and bucket by group (capped), then read the - // buckets in category order — avoids a separate filter pass per group. const byGroup = new Map() for (const item of rawItems) { if (q && !item.label.toLowerCase().includes(q)) continue diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index 38059b69724..134aa00ed69 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -1,12 +1,10 @@ -import type { MouseEvent } from 'react' import type { JSONContent, MarkdownToken } from '@tiptap/core' import { Node } from '@tiptap/core' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' -import { useParams, useRouter } from 'next/navigation' import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' import { mentionIcon } from './mention-icon' -import { simLinkPath, toSimHref } from './sim-link' +import { toSimHref } from './sim-link' import type { MentionKind } from './types' interface MentionAttrs { @@ -44,7 +42,9 @@ interface MentionTokenFields { * to the portable `[label](sim:/)` markdown link — so the saved content is identical to a * plain link (agent-readable, round-trips through the chat's `chip-clipboard-codec`) while the editor * shows it as a chip rather than a blue link. Shared by the headless round-trip path (no node view) - * and the live {@link MentionChip}, mirroring the image node's split. + * and the live {@link MentionChip}, mirroring the image node's split. `renderText` emits the same + * portable link (an atom otherwise contributes no text), so copying a chip into a plain-text target — + * e.g. the chat composer — pastes back as a mention. */ export const MarkdownMention = Node.create({ name: 'mention', @@ -102,8 +102,6 @@ export const MarkdownMention = Node.create({ return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, - // Copy/`getText` emit the portable link (an atom otherwise contributes no text), so copying a chip - // into a plain-text target — e.g. the chat composer — pastes back as a mention. renderText: ({ node }) => { const { kind, id, label } = node.attrs as MentionAttrs return `[${escapeLabel(label)}](${toSimHref(kind, id)})` @@ -119,27 +117,16 @@ export const MarkdownMention = Node.create({ * monochrome through the `--text-icon` fallback below. */ const CHIP_CLASS = - 'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' + 'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' -/** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */ +/** Live chip: a display-only entity icon + label, matching the chat input's mention rendering. */ function MentionChipView({ node }: ReactNodeViewProps) { - const router = useRouter() - const params = useParams() - const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined const { kind, id, label } = node.attrs as MentionAttrs const Icon = mentionIcon(kind, id) as StyleableIcon | undefined const iconStyle = Icon ? getBareIconStyle(Icon) : undefined - const handleClick = (event: MouseEvent) => { - if (!(event.metaKey || event.ctrlKey) || !workspaceId) return - const path = simLinkPath(workspaceId, kind, id) - if (!path) return - event.preventDefault() - router.push(path) - } - return ( - + {Icon && } {label} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index a0347a31bc6..b46998d150d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -28,11 +28,12 @@ declare module '@tiptap/core' { } /** - * Adds the `@` mention menu to the editor. Typing `@` at the start of a block — or after whitespace — - * opens {@link MentionList}; selecting an entity inserts it as a portable `sim:/` markdown - * link (same wire format as the chat composer's `chip-clipboard-codec`), so it round-trips natively - * through the editor's link + markdown machinery. The menu's data is supplied by the host via the - * extension's `mention` storage. + * Adds the `@` mention menu to the editor. Typing `@` at the start of a block — or after whitespace, so + * `@` inside an email/handle (`name@host`) stays literal — opens {@link MentionList}; selecting an + * entity inserts it as a portable `sim:/` markdown link (same wire format as the chat + * composer's `chip-clipboard-codec`), so it round-trips natively through the editor's link + markdown + * machinery. The plugin's `items` is an empty gate; the real list is sourced reactively from the store + * inside {@link MentionList}, populated by the host via the extension's `mention` storage. */ export const Mention = Extension.create, MentionStorage>({ name: 'mention', @@ -56,10 +57,8 @@ export const Mention = Extension.create, MentionStorage>({ } const $from = editor.state.doc.resolve(range.from) if ($from.parentOffset === 0) return true - // Only after whitespace, so `@` inside an email/handle (`name@host`) never triggers. return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset)) }, - // Items are sourced reactively from the store inside MentionList; this only gates the plugin. items: () => [], command: ({ editor, range, props }) => { editor diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts index 11500e400da..b791a310923 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts @@ -39,6 +39,13 @@ interface SuggestionPopupConfig { * floating-ui-positioned body element, repositions on update/scroll, forwards keys to the list's * imperative handle, and tears everything down on exit / Escape / editor-destroy. Shared by the `/` * slash command and the `@` mention menu so the popup mechanics live in exactly one place. + * + * `onStart` runs after an async suggestion update (it awaits `items()`), so it can fire once the editor + * is already destroyed — e.g. a modal closed while the menu was opening — and bails in that case before + * touching the now-unavailable view/storage. The popup mounts inside the host `[role="dialog"]` when + * the editor is in a modal: Radix's scroll-lock blocks wheel events outside the dialog subtree, so a + * body-level popup couldn't be scrolled; `position: fixed` keeps it viewport-positioned (the modal + * centers via flex, no transform) so it isn't clipped. */ export function createSuggestionPopupRenderer( config: SuggestionPopupConfig @@ -63,22 +70,15 @@ export function createSuggestionPopupRenderer return { onStart: (props) => { teardown() - // The suggestion update is async (it awaits `items()`), so onStart can fire after the editor - // was destroyed — e.g. a modal closed while the menu was opening. Bail before touching its - // now-unavailable view/storage. if (props.editor.isDestroyed) return config.onOpen?.(props) component = new ReactRenderer(config.component, { - // ReactRenderer types its props option loosely; the component still enforces P. props: config.mapProps(props) as Record, editor: props.editor, }) popup = document.createElement('div') popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' popup.appendChild(component.element) - // Mount inside the host dialog when the editor is in a modal: Radix's scroll-lock blocks wheel - // events outside the dialog subtree, so a body-level popup can't be scrolled. `position: fixed` - // keeps it viewport-positioned (the modal centers via flex, no transform) so it isn't clipped. const host = props.editor.view.dom.closest('[role="dialog"]') ?? document.body host.appendChild(popup) boundEditor = props.editor diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts index 477ee02c270..23cb3ba4912 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts @@ -23,7 +23,8 @@ interface SuggestionKeyboard extends SuggestionKeyDownHandler { * resets it when the items change, scrolls the active row into view, and exposes an `onKeyDown` handle * for the suggestion plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the * chat composer). The handle is stable and reads live values through a ref, because the suggestion - * plugin captures it once via `ReactRenderer.ref` while the items may still be loading. + * plugin captures it once via `ReactRenderer.ref` while the items may still be loading. Enter/Tab clamp + * the active index in case a filter shrank the list this frame before the active-index reset committed. */ export function useSuggestionKeyboard( items: T[], @@ -57,7 +58,6 @@ export function useSuggestionKeyboard( return true } if (event.key === 'Enter' || event.key === 'Tab') { - // Clamp in case a filter shrank the list this frame before the active-index reset committed. const item = items[Math.min(activeIndex, items.length - 1)] if (!item) return false onSelect(item) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 1322e700c2f..d299a701c18 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -102,6 +102,13 @@ text-decoration: line-through; } +/* Selecting struck-through text reads in the primary color like any other highlighted text, so the + selection looks uniform (it returns to the muted strike color when deselected). */ +.rich-markdown-prose del::selection, +.rich-markdown-prose s::selection { + color: var(--text-primary); +} + .rich-markdown-prose a { color: var(--brand-secondary); cursor: pointer; diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 54624ae8cad..922f56c2a13 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -33,7 +33,7 @@ const EXTENSIONS = createMarkdownEditorExtensions({ placeholder: "Write something, or press '/' for commands…", }) -// Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. +/** Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. */ const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000 const STREAM_REPARSE_THROTTLE_MS = 120 @@ -158,17 +158,17 @@ export function LoadedRichMarkdownEditor({ onChange, onSaveShortcut, }: LoadedRichMarkdownEditorProps) { - // Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. + /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) - // Verdict + frontmatter, locked once (at mount if settled, else on settle); null reads as read-only. + /** Verdict + frontmatter, locked once (at mount if settled, else on settle); null reads as read-only. */ const settledRef = useRef(null) if (!streamingAtMountRef.current && settledRef.current === null) { settledRef.current = lockSettled(content) } const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false) - // Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse. + /** Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse. */ const [initialContent] = useState(() => streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body) ) @@ -185,8 +185,10 @@ export function LoadedRichMarkdownEditor({ onChangeRef.current = onChange const onSaveShortcutRef = useRef(onSaveShortcut) onSaveShortcutRef.current = onSaveShortcut - // Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change - // between sessions within one turn, e.g. an append followed by a rewrite). + /** + * Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change + * between sessions within one turn, e.g. an append followed by a rewrite). + */ const streamIsIncrementalRef = useRef(streamIsIncremental) streamIsIncrementalRef.current = streamIsIncremental const router = useRouter() @@ -197,12 +199,14 @@ export function LoadedRichMarkdownEditor({ const uploadFile = useUploadWorkspaceFile() const editorInstanceRef = useRef(null) - // The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position - // captured when the command ran, so the upload inserts where `/Image` was typed. + /** + * The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position + * captured when the command ran, so the upload inserts where `/Image` was typed. + */ const imageInputRef = useRef(null) const pendingImagePosRef = useRef(null) - // Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest. + /** Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest. */ const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => Promise.resolve() ) @@ -246,12 +250,15 @@ export function LoadedRichMarkdownEditor({ void onSaveShortcutRef.current() return true }, + /** + * Follows a clicked link. While editing a modifier is required (a plain click places the cursor); + * read-only follows directly. A same-page anchor (`[x](#slug)`) scrolls to the matching heading; a + * same-origin in-app path navigates within the SPA (same tab); everything else opens a new tab. + */ handleClick: (view, _pos, event) => { const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') if (!href) return false - // Editing requires a modifier to follow a link (a plain click places the cursor); read-only follows it directly. if (view.editable && !(event.metaKey || event.ctrlKey)) return false - // Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab. if (href.startsWith('#')) { const pos = findHeadingPos(view.state.doc, href.slice(1)) if (pos < 0) return false @@ -263,7 +270,6 @@ export function LoadedRichMarkdownEditor({ } const normalized = normalizeLinkHref(href) if (!normalized) return false - // A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab. if ( !(event.metaKey || event.ctrlKey) && normalized.startsWith('/') && @@ -283,6 +289,11 @@ export function LoadedRichMarkdownEditor({ void insertImagesRef.current(images, view.state.selection.from) return true }, + /** + * Inserts dropped image files at the drop point. Any other file drop (e.g. a PDF) is swallowed so + * the browser doesn't navigate away from the editor; internal text drags carry no files and fall + * through to the default behavior. + */ handleDrop: (view, event) => { if (!view.editable) return false const images = extractImageFiles(event.dataTransfer) @@ -292,8 +303,6 @@ export function LoadedRichMarkdownEditor({ void insertImagesRef.current(images, dropPos ?? view.state.selection.from) return true } - // Swallow any other file drop (e.g. a PDF) so the browser doesn't navigate away from the - // editor. Internal text drags carry no files and fall through to the default behavior. if (event.dataTransfer?.files.length) { event.preventDefault() return true @@ -309,8 +318,10 @@ export function LoadedRichMarkdownEditor({ }) editorInstanceRef.current = editor - // Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is - // shared across instances). Reads only refs, so the handler stays stable across the editor's life. + /** + * Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is + * shared across instances). Reads only refs, so the handler stays stable across the editor's life. + */ useEffect(() => { if (!editor) return editor.storage.slashCommand.insertImage = (at: number) => { @@ -338,7 +349,7 @@ export function LoadedRichMarkdownEditor({ if (body === lastSyncedBodyRef.current) return pendingStreamBodyRef.current = body if (streamRafRef.current !== null) return - // Self-re-arming tick: parse the latest pending body, but throttle a large one (cheap re-check, no parse) until due. + /** Self-re-arming tick: parse the latest pending body, but throttle a large one (cheap re-check, no parse) until due. */ const tick = () => { const pending = pendingStreamBodyRef.current if (pending === null || pending === lastSyncedBodyRef.current) { @@ -347,10 +358,6 @@ export function LoadedRichMarkdownEditor({ } const shownBody = lastSyncedBodyRef.current const extendsShown = shownBody === null || pending.startsWith(shownBody) - // Incremental edits (append/patch) arrive as complete full-file snapshots, so each is applied - // live — ProseMirror diffs the localized change in place (mid-doc rewrite, insertion, delete). - // A rebuild (create/update streamed from scratch) only extends while revealing from empty; once - // a chunk would collapse the established document it is held until settle, avoiding the flicker. if (!streamIsIncrementalRef.current && !extendsShown) { streamRafRef.current = null return @@ -382,7 +389,7 @@ export function LoadedRichMarkdownEditor({ cancelAnimationFrame(streamRafRef.current) streamRafRef.current = null } - // Settle: re-lock the verdict + frontmatter on the freshly-settled content (every stream→settle, not just the first). + /** Settle: re-lock the verdict + frontmatter on the freshly-settled content (every stream→settle, not just the first). */ const isInitialSettle = settledRef.current === null if (isInitialSettle || wasStreamingRef.current) { wasStreamingRef.current = false diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 8432d31911d..3266bea9a7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -66,24 +66,28 @@ export function RichMarkdownField({ }: RichMarkdownFieldProps) { const containerRef = useRef(null) - // Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. - // Split once at mount — the refs and the seed doc all derive from the initial value. + /** + * Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. Split + * once at mount — the refs and the seed doc all derive from this initial value. + */ const [initialSplit] = useState(() => splitFrontmatter(value)) const frontmatterRef = useRef(initialSplit.frontmatter) - // The body last reflected into the editor — updated on local edits and on each streamed sync. + /** The body last reflected into the editor — updated on local edits and on each streamed sync. */ const lastSyncedBodyRef = useRef(initialSplit.body) const onChangeRef = useRef(onChange) onChangeRef.current = onChange const onPasteTextRef = useRef(onPasteText) onPasteTextRef.current = onPasteText - // The original value verbatim, plus its canonical serialization. The editor only ever emits canonical - // markdown, so an already-non-canonical input would re-serialize on mount and read as an unsaved edit; - // reporting the original when the doc matches its canonical form keeps the field clean until a real edit. + /** + * The original value verbatim, plus its canonical serialization. The editor only ever emits canonical + * markdown, so an already-non-canonical input would re-serialize on mount and read as an unsaved edit; + * reporting the original when the doc matches its canonical form keeps the field clean until a real edit. + */ const initialValueRef = useRef(value) const [canonicalSeed] = useState(() => normalizeMarkdownContent(value)) - // TipTap extensions are stateful — build them once per mount so each field gets its own placeholder. + /** TipTap extensions are stateful — build them once per mount so each field gets its own placeholder. */ const [extensions] = useState(() => createMarkdownEditorExtensions({ placeholder })) const [initialContent] = useState(() => parseMarkdownToDoc(initialSplit.body)) @@ -107,9 +111,11 @@ export function RichMarkdownField({ if (!text) return false return handler(text) }, + /** + * The field has no image upload; swallow any file drop so the browser doesn't navigate to the + * dropped file and tear down the modal. Internal text drags carry no files and fall through. + */ handleDrop: (_view, event) => { - // The field has no image upload; swallow any file drop so the browser doesn't navigate to - // the dropped file and tear down the modal. Internal text drags fall through to the default. if (event.dataTransfer?.files.length) { event.preventDefault() return true @@ -125,7 +131,7 @@ export function RichMarkdownField({ }, }) - // Mirror an externally-driven value (AI generation) into the editor, then settle to editable. + /** Mirrors an externally-driven value (AI generation) into the editor, then settles to editable. */ const wasStreamingRef = useRef(isStreaming) useEffect(() => { if (!editor) return @@ -147,7 +153,6 @@ export function RichMarkdownField({ return } - // Settle: re-seed the freshly-generated body once, then restore editability. if (wasStreamingRef.current) { wasStreamingRef.current = false if (body !== lastSyncedBodyRef.current) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts index 9399e91f4e5..5878b352cb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -147,8 +147,6 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [ icon: ImageIcon, aliases: ['picture', 'photo', 'upload', 'img'], run: ({ editor, range }) => { - // Replace the typed `/query`, then hand off to the host component's picker, which uploads and - // inserts the image at the caret (the same path as paste/drop). No-op when no handler is wired. editor.chain().focus().deleteRange(range).run() editor.storage.slashCommand.insertImage?.(editor.state.selection.from) }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index dc7186c4ea6..dca5a69eadd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -21,7 +21,9 @@ export const SLASH_COMMAND_PLUGIN_KEY = new PluginKey('slashCommand') /** * Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after - * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. + * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. The Image + * command appears only where image upload is wired (the file viewer); modal field editors never set + * `insertImage`, so it stays hidden there. */ export const SlashCommand = Extension.create, SlashCommandStorage>({ name: 'slashCommand', @@ -51,8 +53,6 @@ export const SlashCommand = Extension.create, SlashCommand if ($from.parentOffset === 0) return true return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset)) }, - // The Image command is offered only where image upload is wired (the file viewer); the modal - // field editors never set `insertImage`, so `@`-style image insertion is hidden there. items: ({ editor, query }) => filterSlashCommands(query, { allowImages: editor.storage.slashCommand.insertImage != null, diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index d84c5384959..bcf59014162 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -65,8 +65,11 @@ export function SkillModal({ const [name, setName] = useState('') const [description, setDescription] = useState('') const [content, setContent] = useState('') - // Bumped to remount the seed-once rich Content editor whenever `content` is set programmatically — - // a reset from a changed `initialValues` or a destructured SKILL.md paste — so the editor re-seeds. + /** + * Bumped to remount the seed-once rich Content editor whenever `content` is set programmatically — a + * reset from a changed `initialValues` or a destructured SKILL.md paste — so the editor re-seeds (an + * `initialValues` change for the same skill keeps the React key otherwise stable). + */ const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) const [saving, setSaving] = useState(false) @@ -74,16 +77,13 @@ export function SkillModal({ const [prevOpen, setPrevOpen] = useState(false) const [prevInitialValues, setPrevInitialValues] = useState(initialValues) - // Reset only when the *skill* changes (by id), not on a background refetch that returns a new - // object for the same open skill — otherwise an in-progress edit would be silently clobbered. + // Reset by skill id, not object identity — a background refetch for the same open skill must not clobber an in-progress edit. if ((open && !prevOpen) || (open && initialValues?.id !== prevInitialValues?.id)) { setName(initialValues?.name ?? '') setDescription(initialValues?.description ?? '') setContent(initialValues?.content ?? '') setErrors({}) setActiveTab('create') - // Remount the seed-once Content editor so it re-seeds from the reset value (an `initialValues` - // change for the same skill keeps the React key otherwise stable). setContentSeed((seed) => seed + 1) } if (open !== prevOpen) setPrevOpen(open) @@ -190,7 +190,6 @@ export function SkillModal({ - {/* Tab switcher — only on create flow */} {!isEditing && ( (null) - // Abort an in-flight generation if the modal unmounts mid-stream (e.g. the deploy modal closes), - // so the SSE stream stops instead of running to completion against a gone component. + /** + * Abort an in-flight generation if the modal unmounts mid-stream (e.g. the deploy modal closes), so + * the SSE stream stops instead of running to completion against a gone component. + */ useEffect(() => () => generateAbortRef.current?.abort(), []) const handleGenerateDescription = () => { diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index e53b4fa59c6..a6097793ac5 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -500,6 +500,7 @@ export function useGenerateVersionDescription() { logger.info('Generated version description', { length: content.length }) }, onError: (error) => { + if (error.name === 'AbortError') return logger.error('Failed to generate version description', { error }) }, }) From a07fb6691cb317ef69330753906dce770565bda0 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 11:02:21 -0700 Subject: [PATCH 15/32] fix(rich-editor): clean selection for the mention chip The chip is an inline atom, so a range selection now highlights it as a whole unit (the prior select-none left it an un-highlighted gap). A direct click selects it with a subtle fill instead of the block-leaf outline ring meant for dividers/images. --- .../rich-markdown-editor/mention/mention-node.tsx | 2 +- .../rich-markdown-editor/rich-markdown-editor.css | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index 134aa00ed69..8e3c9e482d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -117,7 +117,7 @@ export const MarkdownMention = Node.create({ * monochrome through the `--text-icon` fallback below. */ const CHIP_CLASS = - 'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' + 'mention-chip mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' /** Live chip: a display-only entity icon + label, matching the chat input's mention rendering. */ function MentionChipView({ node }: ReactNodeViewProps) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index d299a701c18..65f04b4377f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -36,6 +36,14 @@ outline-offset: 2px; } +/* The inline mention chip is too small for the block-leaf ring above; a direct click selects it with a + subtle fill instead, so it never wears the heavy divider/image outline. */ +.rich-markdown-prose .mention-chip.ProseMirror-selectednode { + outline: none; + border-radius: 3px; + background: var(--surface-active); +} + .rich-markdown-prose > * + * { margin-top: 0.6em; } From 664821a71594cb1bc49d10c532d926996813ca9b Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 11:10:10 -0700 Subject: [PATCH 16/32] feat(rich-editor): Cmd/Ctrl-click a mention to its resource in the file viewer Threads a `navigable` flag through the mention storage: the file viewer opts in so a chip routes to its file/table/workflow/etc., while modal fields stay inert so a click can't navigate away from an unsaved edit. Styling is identical either way. --- .../mention/mention-node.tsx | 32 ++++++++++++++++--- .../rich-markdown-editor/mention/mention.ts | 6 ++-- .../mention/use-editor-mentions.ts | 15 +++++++-- .../rich-markdown-editor.tsx | 2 +- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index 8e3c9e482d1..c03ae93d206 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -1,10 +1,13 @@ +import type { MouseEvent } from 'react' import type { JSONContent, MarkdownToken } from '@tiptap/core' import { Node } from '@tiptap/core' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { useParams, useRouter } from 'next/navigation' +import { cn } from '@/lib/core/utils/cn' import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' import { mentionIcon } from './mention-icon' -import { toSimHref } from './sim-link' +import { simLinkPath, toSimHref } from './sim-link' import type { MentionKind } from './types' interface MentionAttrs { @@ -119,14 +122,35 @@ export const MarkdownMention = Node.create({ const CHIP_CLASS = 'mention-chip mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' -/** Live chip: a display-only entity icon + label, matching the chat input's mention rendering. */ -function MentionChipView({ node }: ReactNodeViewProps) { +/** + * Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted + * into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays + * inert so a click can't navigate away from an unsaved edit. + */ +function MentionChipView({ node, editor }: ReactNodeViewProps) { + const router = useRouter() + const params = useParams() const { kind, id, label } = node.attrs as MentionAttrs const Icon = mentionIcon(kind, id) as StyleableIcon | undefined const iconStyle = Icon ? getBareIconStyle(Icon) : undefined + const navigable = editor.storage.mention?.navigable === true + + const handleClick = (event: MouseEvent) => { + if (!(event.metaKey || event.ctrlKey)) return + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + const path = workspaceId && simLinkPath(workspaceId, kind, id) + if (!path) return + event.preventDefault() + router.push(path) + } return ( - + {Icon && } {label} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index b46998d150d..6f7af09959e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -13,12 +13,14 @@ export const MENTION_PLUGIN_KEY = new PluginKey('mention') * Per-editor storage for the `@` mention extension. The host component populates {@link store} with * the current workspace mention data and may set {@link onOpen} to lazily start fetching that data the * first time the menu is triggered. {@link enabled} gates the menu off entirely (e.g. a field with no - * workspace scope) so `@` stays literal text. + * workspace scope) so `@` stays literal text. {@link navigable} lets a chip Cmd/Ctrl-click to its + * resource — on for the file viewer, off inside a modal field so it can't route away from an edit. */ export interface MentionStorage { store: MentionStore onOpen: (() => void) | null enabled: boolean + navigable: boolean } declare module '@tiptap/core' { @@ -39,7 +41,7 @@ export const Mention = Extension.create, MentionStorage>({ name: 'mention', addStorage() { - return { store: createMentionStore(), onOpen: null, enabled: true } + return { store: createMentionStore(), onOpen: null, enabled: true, navigable: false } }, addProseMirrorPlugins() { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts index a40776f26ad..edd533d1dd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts @@ -2,24 +2,35 @@ import { useEffect, useState } from 'react' import type { Editor } from '@tiptap/react' import { useMarkdownMentions } from './use-markdown-mentions' +interface UseEditorMentionsOptions { + /** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */ + navigable?: boolean +} + /** * Wires an editor's `@` mention menu to its workspace data: gates the menu on a workspace scope, * lazily fetches the data on the first open, and feeds it into the menu's reactive store. Shared by * every editor surface that mounts the mention extension (the file editor and the modal field). */ -export function useEditorMentions(editor: Editor | null, workspaceId: string | undefined): void { +export function useEditorMentions( + editor: Editor | null, + workspaceId: string | undefined, + options?: UseEditorMentionsOptions +): void { const [active, setActive] = useState(false) const items = useMarkdownMentions(workspaceId, { enabled: active }) + const navigable = options?.navigable ?? false useEffect(() => { if (!editor) return const hasWorkspace = Boolean(workspaceId) editor.storage.mention.enabled = hasWorkspace + editor.storage.mention.navigable = navigable editor.storage.mention.onOpen = hasWorkspace ? () => setActive(true) : null return () => { editor.storage.mention.onOpen = null } - }, [editor, workspaceId]) + }, [editor, workspaceId, navigable]) useEffect(() => { editor?.storage.mention.store.set(items) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 922f56c2a13..52a26347a47 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -333,7 +333,7 @@ export function LoadedRichMarkdownEditor({ } }, [editor]) - useEditorMentions(editor, workspaceId) + useEditorMentions(editor, workspaceId, { navigable: true }) const wasStreamingRef = useRef(streamingAtMountRef.current) From e717cf8e82e6cd2ac1a2d74571ee7a3db699379d Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 11:24:40 -0700 Subject: [PATCH 17/32] fix(rich-editor): icon fallback for removed integrations; smoother divider nav - A mention to a since-removed integration falls back to a generic icon so the chip is never icon-less (indistinguishable from prose) - Arrowing from a selected divider/image to an adjacent one selects it directly instead of stopping on the gap cursor between them, so stepping through a run of dividers is one press each --- .../rich-markdown-editor/keymap.ts | 32 +++++++++++++++++-- .../mention/mention-icon.ts | 11 ++++--- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 4057e8b7f37..f9b930e12cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -1,5 +1,6 @@ import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' +import { NodeSelection } from '@tiptap/pm/state' import { MENTION_PLUGIN_KEY } from './mention' import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' @@ -36,6 +37,26 @@ function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean { ) } +/** + * When a divider/image is already selected, arrowing toward an immediately-adjacent divider/image + * selects that one directly instead of stopping on the gap cursor between them — so stepping through a + * run of dividers is one press each. A non-leaf neighbour (a textblock) falls through to the default, + * which moves the caret into it. + */ +function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): boolean { + const { selection } = editor.state + if (!(selection instanceof NodeSelection) || !SELECTABLE_LEAVES.has(selection.node.type.name)) { + return false + } + const boundary = direction === 'up' ? selection.from : selection.to + const resolved = editor.state.doc.resolve(boundary) + const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter + if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false + return editor.commands.setNodeSelection( + direction === 'up' ? boundary - adjacent.nodeSize : boundary + ) +} + /** * Editor-specific keyboard behavior layered on top of StarterKit's defaults: * @@ -47,7 +68,9 @@ function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean { * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the * block is already fully selected) falls through to the default whole-document select-all, the * same scoped behavior as a code editor. - * - **ArrowUp/ArrowDown** select an adjacent divider or image (see {@link selectAdjacentLeaf}). + * - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge + * ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next + * ({@link selectAdjacentSelectedLeaf}). */ export const RichMarkdownKeymap = Extension.create({ name: 'richMarkdownKeymap', @@ -77,9 +100,12 @@ export const RichMarkdownKeymap = Extension.create({ if (editor.state.selection.from === from && editor.state.selection.to === to) return false return editor.commands.setTextSelection({ from, to }) }, - ArrowUp: ({ editor }) => !isSuggestionMenuOpen(editor) && selectAdjacentLeaf(editor, 'up'), + ArrowUp: ({ editor }) => + !isSuggestionMenuOpen(editor) && + (selectAdjacentSelectedLeaf(editor, 'up') || selectAdjacentLeaf(editor, 'up')), ArrowDown: ({ editor }) => - !isSuggestionMenuOpen(editor) && selectAdjacentLeaf(editor, 'down'), + !isSuggestionMenuOpen(editor) && + (selectAdjacentSelectedLeaf(editor, 'down') || selectAdjacentLeaf(editor, 'down')), } }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts index 9537e54ec50..ff2f64c6023 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'react' -import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' +import { Box, Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' import { getBlock } from '@/blocks/registry' import type { MentionKind } from './types' @@ -17,10 +17,11 @@ const KIND_ICONS: Record, MentionIcon> = { /** * Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by - * blockType, which is the mention `id`); every other kind uses a lucide category icon. Shared by the - * menu rows and the inserted chip so both render the same icon. + * blockType, which is the mention `id`), falling back to a generic icon if the block was since removed + * so the chip is never icon-less; every other kind uses a lucide category icon. Shared by the menu + * rows and the inserted chip so both render the same icon. */ -export function mentionIcon(kind: MentionKind, id: string): MentionIcon | undefined { - if (kind === 'integration') return getBlock(id)?.icon as MentionIcon | undefined +export function mentionIcon(kind: MentionKind, id: string): MentionIcon { + if (kind === 'integration') return (getBlock(id)?.icon as MentionIcon | undefined) ?? Box return KIND_ICONS[kind] } From ce09e02c2b5ee344226aea8a619b1f75fd09ca99 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 11:45:55 -0700 Subject: [PATCH 18/32] fix(rich-editor): integration mentions are display-only; robust chip selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An integration mention's id is a block type (gmail_v2), not a routable resource — /integrations/[block] expects a slug and a type maps to zero-or-many credentials — so it no longer links to a 404. The chip only shows a pointer/navigates on kinds that resolve to a real page. - Scope the block-leaf selection ring off the mention chip robustly (covers a node-view wrapper via :has), so a selected chip shows a subtle fill, not the outline. --- .../mention/mention-node.tsx | 18 +++++++++--------- .../mention/sim-link.test.ts | 5 +++-- .../rich-markdown-editor/mention/sim-link.ts | 11 ++++++----- .../rich-markdown-editor.css | 6 ++++-- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index c03ae93d206..8bea193412f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -131,15 +131,15 @@ function MentionChipView({ node, editor }: ReactNodeViewProps) { const router = useRouter() const params = useParams() const { kind, id, label } = node.attrs as MentionAttrs - const Icon = mentionIcon(kind, id) as StyleableIcon | undefined - const iconStyle = Icon ? getBareIconStyle(Icon) : undefined + const Icon = mentionIcon(kind, id) as StyleableIcon + const iconStyle = getBareIconStyle(Icon) const navigable = editor.storage.mention?.navigable === true + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + // Only show the pointer / route on a kind that actually resolves to a page (e.g. not an integration). + const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null const handleClick = (event: MouseEvent) => { - if (!(event.metaKey || event.ctrlKey)) return - const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined - const path = workspaceId && simLinkPath(workspaceId, kind, id) - if (!path) return + if (!path || !(event.metaKey || event.ctrlKey)) return event.preventDefault() router.push(path) } @@ -147,11 +147,11 @@ function MentionChipView({ node, editor }: ReactNodeViewProps) { return ( - {Icon && } + {label} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts index e33a4359f28..194e3bf6ba9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts @@ -25,10 +25,11 @@ describe('simLinkPath', () => { expect(simLinkPath(ws, 'knowledge', 'k1')).toBe('/workspace/ws1/knowledge/k1') expect(simLinkPath(ws, 'workflow', 'w1')).toBe('/workspace/ws1/w/w1') expect(simLinkPath(ws, 'skill', 's1')).toBe('/workspace/ws1/skills?skillId=s1') - expect(simLinkPath(ws, 'integration', 'slack')).toBe('/workspace/ws1/integrations/slack') }) - it('returns null for an unknown kind', () => { + it('returns null for kinds with no navigable resource (integration) and unknown kinds', () => { + // An integration mention's id is a block type, not a routable resource. + expect(simLinkPath(ws, 'integration', 'slack')).toBeNull() expect(simLinkPath(ws, 'mystery', 'x')).toBeNull() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts index cfb526f44bd..9de4a299e74 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts @@ -19,9 +19,12 @@ export function parseSimHref(href: string): { kind: string; id: string } | null } /** - * Resolves the in-app route for a clicked `sim:` mention link, or `null` for an unknown kind. Each - * destination matches the entity's real route: files open the file detail view, folders/skills deep-link - * the file browser / skills modal via their query params, the rest hit their `[id]` route. + * Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable + * destination. Each path matches the entity's real route: files open the file detail view, + * folders/skills deep-link the file browser / skills modal via their query params, the rest hit their + * `[id]` route. Integrations are intentionally non-navigable — a mention's id is a block *type* + * (`gmail_v2`), which isn't a routable resource (no per-type page; it maps to zero-or-many + * credentials), so the chip stays display-only. */ export function simLinkPath(workspaceId: string, kind: string, id: string): string | null { const base = `/workspace/${workspaceId}` @@ -38,8 +41,6 @@ export function simLinkPath(workspaceId: string, kind: string, id: string): stri return `${base}/w/${id}` case 'skill': return `${base}/skills?skillId=${id}` - case 'integration': - return `${base}/integrations/${id}` default: return null } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 65f04b4377f..31ff3b2cd5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -37,8 +37,10 @@ } /* The inline mention chip is too small for the block-leaf ring above; a direct click selects it with a - subtle fill instead, so it never wears the heavy divider/image outline. */ -.rich-markdown-prose .mention-chip.ProseMirror-selectednode { + subtle fill instead, so it never wears the heavy divider/image outline. `:has` covers the case where + ProseMirror puts `selectednode` on a wrapper around the chip span rather than the span itself. */ +.rich-markdown-prose .mention-chip.ProseMirror-selectednode, +.rich-markdown-prose .ProseMirror-selectednode:has(.mention-chip) { outline: none; border-radius: 3px; background: var(--surface-active); From 3e52a25afafa0891403ba3699013bd0ac12519ee Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 11:47:04 -0700 Subject: [PATCH 19/32] feat(icypeas): update brand icon and bgColor; regenerate integration docs --- apps/docs/components/icons.tsx | 72 +++--- .../content/docs/en/integrations/file.mdx | 29 ++- .../content/docs/en/integrations/icypeas.mdx | 2 +- .../docs/en/integrations/pagerduty.mdx | 242 ++++++++++++++++++ .../content/docs/en/integrations/supabase.mdx | 45 +++- .../content/docs/en/integrations/table.mdx | 6 +- .../content/docs/en/integrations/zendesk.mdx | 207 +++++++++++++++ apps/sim/blocks/blocks/icypeas.ts | 2 +- apps/sim/components/icons.tsx | 32 ++- apps/sim/lib/integrations/integrations.json | 4 +- 10 files changed, 572 insertions(+), 69 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 109c9b2cf45..f0385fce786 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -3425,36 +3425,6 @@ export function AzureIcon(props: SVGProps) { ) } -export function AzureDevOpsIcon(props: SVGProps) { - const id = useId() - const gradientId = `azure_devops_gradient_${id}` - return ( - - - - - - - - - - - - - ) -} - export const GroqIcon = (props: SVGProps) => ( ) => ( ) +export const SakanaIcon = (props: SVGProps) => ( + + Sakana AI + + +) + export function GeminiIcon(props: SVGProps) { const id = useId() const gradientId = `gemini_gradient_${id}` @@ -7989,20 +7969,28 @@ export function DropcontactIcon(props: SVGProps) { ) } -/** Icypeas brand icon: dark tile with the teal ring + rising-chart mark. */ +/** Icypeas brand icon: light tile with the teal ring + rising-step mark. */ export function IcypeasIcon(props: SVGProps) { return ( - - - - - + + + + + + + + + + + ) } diff --git a/apps/docs/content/docs/en/integrations/file.mdx b/apps/docs/content/docs/en/integrations/file.mdx index 92542762fb8..f4c7da583c9 100644 --- a/apps/docs/content/docs/en/integrations/file.mdx +++ b/apps/docs/content/docs/en/integrations/file.mdx @@ -1,6 +1,6 @@ --- title: File -description: Read, get content, fetch, write, append, compress, and decompress files +description: Read, get content, fetch, write, append, compress, decompress, and manage sharing for files --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -12,7 +12,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" ## Usage Instructions -Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, or extract a .zip archive into the workspace. +Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file. @@ -147,4 +147,29 @@ Extract the contents of a .zip archive into the workspace, preserving the archiv | --------- | ---- | ----------- | | `files` | file[] | Extracted workspace file objects | +### `file_manage_sharing` + +Enable or disable the public share link for a workspace file, and set its access mode (public, password, email, or SSO). Idempotent: the public link stays stable across changes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `fileId` | string | No | Canonical ID of the workspace file to update sharing for. | +| `fileInput` | file | No | Selected workspace file object \(from the file picker\). | +| `isActive` | boolean | Yes | Whether the public link is enabled. Set to false to make the file private. | +| `authType` | string | No | Access mode for the link: "public", "password", "email", or "sso". Defaults to "public". | +| `password` | string | No | Password to protect the link. Required when authType is "password". | +| `allowedEmails` | array | No | Allowed emails or "@domain" patterns. Required when authType is "email" or "sso". | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `url` | string | Public share URL for the file | +| `isActive` | boolean | Whether the public link is enabled | +| `authType` | string | Access mode: public, password, email, or sso | +| `hasPassword` | boolean | Whether the share is password-protected | +| `allowedEmails` | array | Allowed emails/domains for email or SSO access | + diff --git a/apps/docs/content/docs/en/integrations/icypeas.mdx b/apps/docs/content/docs/en/integrations/icypeas.mdx index e24c4e733dd..f33240266fc 100644 --- a/apps/docs/content/docs/en/integrations/icypeas.mdx +++ b/apps/docs/content/docs/en/integrations/icypeas.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/docs/content/docs/en/integrations/pagerduty.mdx b/apps/docs/content/docs/en/integrations/pagerduty.mdx index 01e870eba61..a7ffe68650e 100644 --- a/apps/docs/content/docs/en/integrations/pagerduty.mdx +++ b/apps/docs/content/docs/en/integrations/pagerduty.mdx @@ -215,3 +215,245 @@ List current on-call entries from PagerDuty. | `more` | boolean | Whether more results are available | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### PagerDuty Incident Acknowledged + +Trigger workflow when an incident is acknowledged in PagerDuty + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Used to create the webhook subscription. Must be a read/write REST API key. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Event type \(e.g. incident.triggered, incident.resolved\) | +| `occurred_at` | string | When the event occurred \(ISO 8601\) | +| `agent` | json | The user or service that caused the event \(may be null\) | +| `incident` | object | incident output from the tool | +| ↳ `id` | string | Incident ID | +| ↳ `number` | number | Incident number | +| ↳ `title` | string | Incident title | +| ↳ `status` | string | Incident status \(triggered, acknowledged, resolved\) | +| ↳ `urgency` | string | Incident urgency \(high or low\) | +| ↳ `html_url` | string | Web URL of the incident | +| ↳ `created_at` | string | Incident creation timestamp | +| ↳ `priority` | string | Priority label \(may be null\) | +| ↳ `service` | object | service output from the tool | +| ↳ `id` | string | Service ID | +| ↳ `summary` | string | Service name | +| ↳ `html_url` | string | Service web URL | +| ↳ `escalation_policy` | object | escalation_policy output from the tool | +| ↳ `id` | string | Escalation policy ID | +| ↳ `summary` | string | Escalation policy name | +| ↳ `html_url` | string | Escalation policy web URL | +| ↳ `assignees` | json | Array of assignee references \(\{ id, summary, html_url \}\) | + + +--- + +### PagerDuty Incident Escalated + +Trigger workflow when an incident is escalated in PagerDuty + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Used to create the webhook subscription. Must be a read/write REST API key. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Event type \(e.g. incident.triggered, incident.resolved\) | +| `occurred_at` | string | When the event occurred \(ISO 8601\) | +| `agent` | json | The user or service that caused the event \(may be null\) | +| `incident` | object | incident output from the tool | +| ↳ `id` | string | Incident ID | +| ↳ `number` | number | Incident number | +| ↳ `title` | string | Incident title | +| ↳ `status` | string | Incident status \(triggered, acknowledged, resolved\) | +| ↳ `urgency` | string | Incident urgency \(high or low\) | +| ↳ `html_url` | string | Web URL of the incident | +| ↳ `created_at` | string | Incident creation timestamp | +| ↳ `priority` | string | Priority label \(may be null\) | +| ↳ `service` | object | service output from the tool | +| ↳ `id` | string | Service ID | +| ↳ `summary` | string | Service name | +| ↳ `html_url` | string | Service web URL | +| ↳ `escalation_policy` | object | escalation_policy output from the tool | +| ↳ `id` | string | Escalation policy ID | +| ↳ `summary` | string | Escalation policy name | +| ↳ `html_url` | string | Escalation policy web URL | +| ↳ `assignees` | json | Array of assignee references \(\{ id, summary, html_url \}\) | + + +--- + +### PagerDuty Incident Event + +Trigger workflow from any PagerDuty incident event + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Used to create the webhook subscription. Must be a read/write REST API key. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Event type \(e.g. incident.triggered, incident.resolved\) | +| `occurred_at` | string | When the event occurred \(ISO 8601\) | +| `agent` | json | The user or service that caused the event \(may be null\) | +| `incident` | object | incident output from the tool | +| ↳ `id` | string | Incident ID | +| ↳ `number` | number | Incident number | +| ↳ `title` | string | Incident title | +| ↳ `status` | string | Incident status \(triggered, acknowledged, resolved\) | +| ↳ `urgency` | string | Incident urgency \(high or low\) | +| ↳ `html_url` | string | Web URL of the incident | +| ↳ `created_at` | string | Incident creation timestamp | +| ↳ `priority` | string | Priority label \(may be null\) | +| ↳ `service` | object | service output from the tool | +| ↳ `id` | string | Service ID | +| ↳ `summary` | string | Service name | +| ↳ `html_url` | string | Service web URL | +| ↳ `escalation_policy` | object | escalation_policy output from the tool | +| ↳ `id` | string | Escalation policy ID | +| ↳ `summary` | string | Escalation policy name | +| ↳ `html_url` | string | Escalation policy web URL | +| ↳ `assignees` | json | Array of assignee references \(\{ id, summary, html_url \}\) | + + +--- + +### PagerDuty Incident Reassigned + +Trigger workflow when an incident is reassigned in PagerDuty + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Used to create the webhook subscription. Must be a read/write REST API key. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Event type \(e.g. incident.triggered, incident.resolved\) | +| `occurred_at` | string | When the event occurred \(ISO 8601\) | +| `agent` | json | The user or service that caused the event \(may be null\) | +| `incident` | object | incident output from the tool | +| ↳ `id` | string | Incident ID | +| ↳ `number` | number | Incident number | +| ↳ `title` | string | Incident title | +| ↳ `status` | string | Incident status \(triggered, acknowledged, resolved\) | +| ↳ `urgency` | string | Incident urgency \(high or low\) | +| ↳ `html_url` | string | Web URL of the incident | +| ↳ `created_at` | string | Incident creation timestamp | +| ↳ `priority` | string | Priority label \(may be null\) | +| ↳ `service` | object | service output from the tool | +| ↳ `id` | string | Service ID | +| ↳ `summary` | string | Service name | +| ↳ `html_url` | string | Service web URL | +| ↳ `escalation_policy` | object | escalation_policy output from the tool | +| ↳ `id` | string | Escalation policy ID | +| ↳ `summary` | string | Escalation policy name | +| ↳ `html_url` | string | Escalation policy web URL | +| ↳ `assignees` | json | Array of assignee references \(\{ id, summary, html_url \}\) | + + +--- + +### PagerDuty Incident Resolved + +Trigger workflow when an incident is resolved in PagerDuty + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Used to create the webhook subscription. Must be a read/write REST API key. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Event type \(e.g. incident.triggered, incident.resolved\) | +| `occurred_at` | string | When the event occurred \(ISO 8601\) | +| `agent` | json | The user or service that caused the event \(may be null\) | +| `incident` | object | incident output from the tool | +| ↳ `id` | string | Incident ID | +| ↳ `number` | number | Incident number | +| ↳ `title` | string | Incident title | +| ↳ `status` | string | Incident status \(triggered, acknowledged, resolved\) | +| ↳ `urgency` | string | Incident urgency \(high or low\) | +| ↳ `html_url` | string | Web URL of the incident | +| ↳ `created_at` | string | Incident creation timestamp | +| ↳ `priority` | string | Priority label \(may be null\) | +| ↳ `service` | object | service output from the tool | +| ↳ `id` | string | Service ID | +| ↳ `summary` | string | Service name | +| ↳ `html_url` | string | Service web URL | +| ↳ `escalation_policy` | object | escalation_policy output from the tool | +| ↳ `id` | string | Escalation policy ID | +| ↳ `summary` | string | Escalation policy name | +| ↳ `html_url` | string | Escalation policy web URL | +| ↳ `assignees` | json | Array of assignee references \(\{ id, summary, html_url \}\) | + + +--- + +### PagerDuty Incident Triggered + +Trigger workflow when a new incident is triggered in PagerDuty + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Used to create the webhook subscription. Must be a read/write REST API key. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Event type \(e.g. incident.triggered, incident.resolved\) | +| `occurred_at` | string | When the event occurred \(ISO 8601\) | +| `agent` | json | The user or service that caused the event \(may be null\) | +| `incident` | object | incident output from the tool | +| ↳ `id` | string | Incident ID | +| ↳ `number` | number | Incident number | +| ↳ `title` | string | Incident title | +| ↳ `status` | string | Incident status \(triggered, acknowledged, resolved\) | +| ↳ `urgency` | string | Incident urgency \(high or low\) | +| ↳ `html_url` | string | Web URL of the incident | +| ↳ `created_at` | string | Incident creation timestamp | +| ↳ `priority` | string | Priority label \(may be null\) | +| ↳ `service` | object | service output from the tool | +| ↳ `id` | string | Service ID | +| ↳ `summary` | string | Service name | +| ↳ `html_url` | string | Service web URL | +| ↳ `escalation_policy` | object | escalation_policy output from the tool | +| ↳ `id` | string | Escalation policy ID | +| ↳ `summary` | string | Escalation policy name | +| ↳ `html_url` | string | Escalation policy web URL | +| ↳ `assignees` | json | Array of assignee references \(\{ id, summary, html_url \}\) | + diff --git a/apps/docs/content/docs/en/integrations/supabase.mdx b/apps/docs/content/docs/en/integrations/supabase.mdx index 46cfa5a9bdb..1741ceb55a1 100644 --- a/apps/docs/content/docs/en/integrations/supabase.mdx +++ b/apps/docs/content/docs/en/integrations/supabase.mdx @@ -36,7 +36,7 @@ Whether you’re building internal tools, automating business processes, or powe ## Usage Instructions -Integrate Supabase into the workflow. Supports database operations (query, insert, update, delete, upsert), full-text search, RPC functions, row counting, vector search, and complete storage management (upload, download, list, move, copy, delete files and buckets). +Integrate Supabase into the workflow. Supports database operations (query, insert, update, delete, upsert), full-text search, RPC functions, Edge Function invocation, row counting, vector search, and complete storage management (upload, download, list, move, copy, delete files and buckets). @@ -165,6 +165,7 @@ Insert or update data in a Supabase table (upsert operation) | `table` | string | Yes | The name of the Supabase table to upsert data into | | `schema` | string | No | Database schema to upsert into \(default: public\). Use this to access tables in other schemas. | | `data` | array | Yes | The data to upsert \(insert or update\) - array of objects or a single object | +| `onConflict` | string | No | Comma-separated column\(s\) with a unique or primary key constraint to resolve conflicts on \(e.g., "email"\). Defaults to the primary key. | | `apiKey` | string | Yes | Your Supabase service role secret key | #### Output @@ -263,6 +264,28 @@ Call a PostgreSQL function in Supabase | `message` | string | Operation status message | | `results` | json | Result returned from the function | +### `supabase_invoke_function` + +Invoke a Supabase Edge Function over HTTP + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) | +| `functionName` | string | Yes | The name of the Edge Function to invoke \(e.g., "hello-world"\) | +| `method` | string | No | HTTP method to use: GET, POST, PUT, PATCH, or DELETE \(default: POST\) | +| `body` | json | No | Request payload to send to the function as a JSON object \(ignored for GET\) | +| `headers` | json | No | Additional request headers as a JSON object of header name to value | +| `apiKey` | string | Yes | Your Supabase service role secret key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `results` | json | Response body returned by the Edge Function | + ### `supabase_introspect` Introspect Supabase database schema to get table structures, columns, and relationships @@ -318,6 +341,7 @@ Upload a file to a Supabase storage bucket | `path` | string | No | Optional folder path \(e.g., "folder/subfolder/"\) | | `fileData` | json | Yes | File to upload - UserFile object \(basic mode\) or string content \(advanced mode: base64 or plain text\). Supports data URLs. | | `contentType` | string | No | MIME type of the file \(e.g., "image/jpeg", "text/plain"\) | +| `cacheControl` | string | No | Cache-Control header value in seconds for the stored object \(e.g., "3600"; default: "3600"\) | | `upsert` | boolean | No | If true, overwrites existing file \(default: false\) | | `apiKey` | string | Yes | Your Supabase service role secret key | @@ -327,9 +351,11 @@ Upload a file to a Supabase storage bucket | --------- | ---- | ----------- | | `message` | string | Operation status message | | `results` | object | Upload result including file path, bucket, and public URL | -| ↳ `id` | string | Unique identifier for the uploaded file | +| ↳ `Id` | string | Unique identifier for the uploaded file | +| ↳ `Key` | string | Full object key including bucket name | | ↳ `path` | string | Path to the uploaded file within the bucket | -| ↳ `fullPath` | string | Full path including bucket name | +| ↳ `bucket` | string | Name of the bucket the file was uploaded to | +| ↳ `publicUrl` | string | Public URL for the uploaded file | ### `supabase_storage_download` @@ -364,7 +390,7 @@ List files in a Supabase storage bucket | `path` | string | No | The folder path to list files from \(default: root\) | | `limit` | number | No | Maximum number of files to return \(default: 100\) | | `offset` | number | No | Number of files to skip \(for pagination\) | -| `sortBy` | string | No | Column to sort by: name, created_at, updated_at \(default: name\) | +| `sortBy` | string | No | Column to sort by: name, created_at, updated_at, last_accessed_at \(default: name\) | | `sortOrder` | string | No | Sort order: asc or desc \(default: asc\) | | `search` | string | No | Search term to filter files by name | | `apiKey` | string | Yes | Your Supabase service role secret key | @@ -437,6 +463,8 @@ Move a file within a Supabase storage bucket | `message` | string | Operation status message | | `results` | object | Move operation result | | ↳ `message` | string | Operation status message | +| ↳ `Id` | string | Identifier of the destination object | +| ↳ `Key` | string | Full object key of the destination | ### `supabase_storage_copy` @@ -457,8 +485,9 @@ Copy a file within a Supabase storage bucket | Parameter | Type | Description | | --------- | ---- | ----------- | | `message` | string | Operation status message | -| `results` | object | Copy operation result | -| ↳ `message` | string | Operation status message | +| `results` | object | Copy operation result with the destination object key | +| ↳ `Key` | string | Full object key of the copied file | +| ↳ `Id` | string | Identifier of the copied object | ### `supabase_storage_create_bucket` @@ -480,7 +509,7 @@ Create a new storage bucket in Supabase | Parameter | Type | Description | | --------- | ---- | ----------- | | `message` | string | Operation status message | -| `results` | object | Created bucket information | +| `results` | object | Created bucket result \(name\) | | ↳ `name` | string | Created bucket name | ### `supabase_storage_list_buckets` @@ -541,7 +570,7 @@ Get the public URL for a file in a Supabase storage bucket | `bucket` | string | Yes | The name of the storage bucket | | `path` | string | Yes | The path to the file \(e.g., "folder/file.jpg"\) | | `download` | boolean | No | If true, forces download instead of inline display \(default: false\) | -| `apiKey` | string | Yes | Your Supabase service role secret key | +| `output` | string | No | No description | #### Output diff --git a/apps/docs/content/docs/en/integrations/table.mdx b/apps/docs/content/docs/en/integrations/table.mdx index 25be3d7034c..df92f381e0e 100644 --- a/apps/docs/content/docs/en/integrations/table.mdx +++ b/apps/docs/content/docs/en/integrations/table.mdx @@ -109,6 +109,7 @@ Insert or update a row based on unique column constraints. If a row with matchin | --------- | ---- | -------- | ----------- | | `tableId` | string | Yes | Table ID | | `data` | object | Yes | Row data to insert or update | +| `conflictTarget` | string | No | Unique column to match on. Required only when the table has more than one unique column. | #### Output @@ -261,7 +262,10 @@ Get the schema configuration of a table | --------- | ---- | ----------- | | `success` | boolean | Whether schema was retrieved | | `name` | string | Table name | -| `columns` | array | Column definitions | +| `columns` | array | Column definitions \(each includes its stable id\) | +| `columnCount` | number | Number of columns | +| `rowCount` | number | Number of rows in the table | +| `maxRows` | number | Max rows per table for the workspace's plan | | `message` | string | Status message | {/* MANUAL-CONTENT-START:notes */} diff --git a/apps/docs/content/docs/en/integrations/zendesk.mdx b/apps/docs/content/docs/en/integrations/zendesk.mdx index dd534df6134..b485b521964 100644 --- a/apps/docs/content/docs/en/integrations/zendesk.mdx +++ b/apps/docs/content/docs/en/integrations/zendesk.mdx @@ -1285,3 +1285,210 @@ Count the number of search results matching a query in Zendesk | `count` | number | Number of matching results | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Zendesk Ticket Comment Added + +Trigger workflow when a comment is added to a Zendesk ticket + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | Your Zendesk subdomain. | +| `email` | string | Yes | Email of a Zendesk admin used with the API token. | +| `apiToken` | string | Yes | Used to create the webhook. Requires admin access. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Full event type \(e.g. zen:event-type:ticket.created\) | +| `time` | string | When the event occurred \(ISO 8601\) | +| `account_id` | number | Zendesk account ID | +| `ticket` | object | ticket output from the tool | +| ↳ `id` | string | Ticket ID | +| ↳ `subject` | string | Ticket subject | +| ↳ `status` | string | Ticket status \(new, open, pending, solved, etc.\) | +| ↳ `priority` | string | Ticket priority \(low, normal, high, urgent\) | +| ↳ `ticket_type` | string | Ticket type \(question, incident, problem, task\) | +| ↳ `description` | string | Ticket description | +| ↳ `requester_id` | string | ID of the requester | +| ↳ `assignee_id` | string | ID of the assignee | +| ↳ `group_id` | string | ID of the assigned group | +| ↳ `organization_id` | string | ID of the organization | +| ↳ `tags` | json | Array of ticket tags | +| ↳ `via_channel` | string | Channel the ticket came in through | +| ↳ `is_public` | boolean | Whether the ticket is public | +| ↳ `created_at` | string | Ticket creation timestamp | +| ↳ `updated_at` | string | Ticket last update timestamp | +| `event` | json | Event-specific changed data \(e.g. status/priority diff\) | + + +--- + +### Zendesk Ticket Created + +Trigger workflow when a new ticket is created in Zendesk + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | Your Zendesk subdomain. | +| `email` | string | Yes | Email of a Zendesk admin used with the API token. | +| `apiToken` | string | Yes | Used to create the webhook. Requires admin access. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Full event type \(e.g. zen:event-type:ticket.created\) | +| `time` | string | When the event occurred \(ISO 8601\) | +| `account_id` | number | Zendesk account ID | +| `ticket` | object | ticket output from the tool | +| ↳ `id` | string | Ticket ID | +| ↳ `subject` | string | Ticket subject | +| ↳ `status` | string | Ticket status \(new, open, pending, solved, etc.\) | +| ↳ `priority` | string | Ticket priority \(low, normal, high, urgent\) | +| ↳ `ticket_type` | string | Ticket type \(question, incident, problem, task\) | +| ↳ `description` | string | Ticket description | +| ↳ `requester_id` | string | ID of the requester | +| ↳ `assignee_id` | string | ID of the assignee | +| ↳ `group_id` | string | ID of the assigned group | +| ↳ `organization_id` | string | ID of the organization | +| ↳ `tags` | json | Array of ticket tags | +| ↳ `via_channel` | string | Channel the ticket came in through | +| ↳ `is_public` | boolean | Whether the ticket is public | +| ↳ `created_at` | string | Ticket creation timestamp | +| ↳ `updated_at` | string | Ticket last update timestamp | +| `event` | json | Event-specific changed data \(e.g. status/priority diff\) | + + +--- + +### Zendesk Ticket Event + +Trigger workflow from any Zendesk ticket event + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | Your Zendesk subdomain. | +| `email` | string | Yes | Email of a Zendesk admin used with the API token. | +| `apiToken` | string | Yes | Used to create the webhook. Requires admin access. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Full event type \(e.g. zen:event-type:ticket.created\) | +| `time` | string | When the event occurred \(ISO 8601\) | +| `account_id` | number | Zendesk account ID | +| `ticket` | object | ticket output from the tool | +| ↳ `id` | string | Ticket ID | +| ↳ `subject` | string | Ticket subject | +| ↳ `status` | string | Ticket status \(new, open, pending, solved, etc.\) | +| ↳ `priority` | string | Ticket priority \(low, normal, high, urgent\) | +| ↳ `ticket_type` | string | Ticket type \(question, incident, problem, task\) | +| ↳ `description` | string | Ticket description | +| ↳ `requester_id` | string | ID of the requester | +| ↳ `assignee_id` | string | ID of the assignee | +| ↳ `group_id` | string | ID of the assigned group | +| ↳ `organization_id` | string | ID of the organization | +| ↳ `tags` | json | Array of ticket tags | +| ↳ `via_channel` | string | Channel the ticket came in through | +| ↳ `is_public` | boolean | Whether the ticket is public | +| ↳ `created_at` | string | Ticket creation timestamp | +| ↳ `updated_at` | string | Ticket last update timestamp | +| `event` | json | Event-specific changed data \(e.g. status/priority diff\) | + + +--- + +### Zendesk Ticket Priority Changed + +Trigger workflow when a ticket priority changes in Zendesk + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | Your Zendesk subdomain. | +| `email` | string | Yes | Email of a Zendesk admin used with the API token. | +| `apiToken` | string | Yes | Used to create the webhook. Requires admin access. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Full event type \(e.g. zen:event-type:ticket.created\) | +| `time` | string | When the event occurred \(ISO 8601\) | +| `account_id` | number | Zendesk account ID | +| `ticket` | object | ticket output from the tool | +| ↳ `id` | string | Ticket ID | +| ↳ `subject` | string | Ticket subject | +| ↳ `status` | string | Ticket status \(new, open, pending, solved, etc.\) | +| ↳ `priority` | string | Ticket priority \(low, normal, high, urgent\) | +| ↳ `ticket_type` | string | Ticket type \(question, incident, problem, task\) | +| ↳ `description` | string | Ticket description | +| ↳ `requester_id` | string | ID of the requester | +| ↳ `assignee_id` | string | ID of the assignee | +| ↳ `group_id` | string | ID of the assigned group | +| ↳ `organization_id` | string | ID of the organization | +| ↳ `tags` | json | Array of ticket tags | +| ↳ `via_channel` | string | Channel the ticket came in through | +| ↳ `is_public` | boolean | Whether the ticket is public | +| ↳ `created_at` | string | Ticket creation timestamp | +| ↳ `updated_at` | string | Ticket last update timestamp | +| `event` | json | Event-specific changed data \(e.g. status/priority diff\) | + + +--- + +### Zendesk Ticket Status Changed + +Trigger workflow when a ticket status changes in Zendesk + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | Your Zendesk subdomain. | +| `email` | string | Yes | Email of a Zendesk admin used with the API token. | +| `apiToken` | string | Yes | Used to create the webhook. Requires admin access. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event_id` | string | Unique ID of the webhook event | +| `event_type` | string | Full event type \(e.g. zen:event-type:ticket.created\) | +| `time` | string | When the event occurred \(ISO 8601\) | +| `account_id` | number | Zendesk account ID | +| `ticket` | object | ticket output from the tool | +| ↳ `id` | string | Ticket ID | +| ↳ `subject` | string | Ticket subject | +| ↳ `status` | string | Ticket status \(new, open, pending, solved, etc.\) | +| ↳ `priority` | string | Ticket priority \(low, normal, high, urgent\) | +| ↳ `ticket_type` | string | Ticket type \(question, incident, problem, task\) | +| ↳ `description` | string | Ticket description | +| ↳ `requester_id` | string | ID of the requester | +| ↳ `assignee_id` | string | ID of the assignee | +| ↳ `group_id` | string | ID of the assigned group | +| ↳ `organization_id` | string | ID of the organization | +| ↳ `tags` | json | Array of ticket tags | +| ↳ `via_channel` | string | Channel the ticket came in through | +| ↳ `is_public` | boolean | Whether the ticket is public | +| ↳ `created_at` | string | Ticket creation timestamp | +| ↳ `updated_at` | string | Ticket last update timestamp | +| `event` | json | Event-specific changed data \(e.g. status/priority diff\) | + diff --git a/apps/sim/blocks/blocks/icypeas.ts b/apps/sim/blocks/blocks/icypeas.ts index 45fdb69150b..373d0bf5f06 100644 --- a/apps/sim/blocks/blocks/icypeas.ts +++ b/apps/sim/blocks/blocks/icypeas.ts @@ -11,7 +11,7 @@ export const IcypeasBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/tools/icypeas', category: 'tools', integrationType: IntegrationType.Sales, - bgColor: '#0EA5E9', + bgColor: '#d4d4d4', icon: IcypeasIcon, authMode: AuthMode.ApiKey, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 834daee7fdd..f0385fce786 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -7969,20 +7969,28 @@ export function DropcontactIcon(props: SVGProps) { ) } -/** Icypeas brand icon: dark tile with the teal ring + rising-chart mark. */ +/** Icypeas brand icon: light tile with the teal ring + rising-step mark. */ export function IcypeasIcon(props: SVGProps) { return ( - - - - - + + + + + + + + + + + ) } diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index dfaab88171c..9eafada0b26 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-06-25", + "updatedAt": "2026-06-26", "integrations": [ { "type": "onepassword", @@ -7727,7 +7727,7 @@ "name": "Icypeas", "description": "Find and verify professional email addresses", "longDescription": "Integrate Icypeas to find a professional email address from a name and company domain, or verify whether an existing email is valid and deliverable. Results are returned asynchronously via polling.", - "bgColor": "#0EA5E9", + "bgColor": "#d4d4d4", "iconName": "IcypeasIcon", "docsUrl": "https://docs.sim.ai/tools/icypeas", "operations": [ From 8ede64a468dbd8c9669f06215dc9c046a881fcaa Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 11:54:57 -0700 Subject: [PATCH 20/32] fix(rich-editor): remove the misfiring chip selection fill (full-width gray band) The :has fill could paint a full-width band; drop it. A selected chip just skips the block-leaf outline ring and uses the same native text-selection highlight as the prose. --- .../rich-markdown-editor/rich-markdown-editor.css | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 31ff3b2cd5a..83fac9149aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -36,14 +36,12 @@ outline-offset: 2px; } -/* The inline mention chip is too small for the block-leaf ring above; a direct click selects it with a - subtle fill instead, so it never wears the heavy divider/image outline. `:has` covers the case where - ProseMirror puts `selectednode` on a wrapper around the chip span rather than the span itself. */ +/* The inline mention chip isn't a block leaf, so it skips the heavy outline ring above and just uses + the same native text-selection highlight as the surrounding prose. ProseMirror puts `selectednode` + on the node-view wrapper, so match it via `:has` (the chip span itself when it's the selected node). */ .rich-markdown-prose .mention-chip.ProseMirror-selectednode, .rich-markdown-prose .ProseMirror-selectednode:has(.mention-chip) { outline: none; - border-radius: 3px; - background: var(--surface-active); } .rich-markdown-prose > * + * { From 5751a0dac82bcd2e60feb9502251e9c747ff7707 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 12:19:02 -0700 Subject: [PATCH 21/32] =?UTF-8?q?feat(rich-editor):=20show=20an=20"Uploadi?= =?UTF-8?q?ng=E2=80=A6"=20toast=20while=20an=20image=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A persistent progress toast appears per image during upload and is dismissed once it settles, when the upload hook's "Uploaded"/"Failed" toast takes over — previously nothing showed until the upload finished. --- .../rich-markdown-editor/rich-markdown-editor.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 52a26347a47..c0ac83bfe25 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -5,6 +5,7 @@ import type { JSONContent } from '@tiptap/core' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' +import { toast } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' @@ -213,9 +214,13 @@ export function LoadedRichMarkdownEditor({ insertImagesRef.current = async (images, at) => { let position = at for (const image of images) { + // Persistent (`duration: 0`) progress toast, dismissed once the upload settles — the upload + // hook then shows its own "Uploaded"/"Failed" toast. + const uploadingToastId = toast.info(`Uploading "${image.name}"…`, { duration: 0 }) const result = await uploadFile .mutateAsync({ workspaceId, file: image, folderId: file.folderId ?? null }) .catch(() => null) + toast.dismiss(uploadingToastId) const editor = editorInstanceRef.current if (!result || !editor) continue const safePosition = Math.min(position, editor.state.doc.content.size) From bc7762b3441f7da95968432b4c995ad8bd026fea Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 12:27:49 -0700 Subject: [PATCH 22/32] refactor(rich-editor): drop inline comments (TSDoc on the declarations instead) Fold the image-upload toast note into the insert function's TSDoc and remove the remaining inline // comments. --- .../rich-markdown-editor/mention/mention-node.tsx | 1 - .../rich-markdown-editor/rich-markdown-editor.tsx | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index 8bea193412f..f7d97d3c796 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -135,7 +135,6 @@ function MentionChipView({ node, editor }: ReactNodeViewProps) { const iconStyle = getBareIconStyle(Icon) const navigable = editor.storage.mention?.navigable === true const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined - // Only show the pointer / route on a kind that actually resolves to a page (e.g. not an integration). const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null const handleClick = (event: MouseEvent) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index c0ac83bfe25..626ad49172b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -104,7 +104,6 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ return ( (null) const pendingImagePosRef = useRef(null) - /** Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest. */ + /** + * Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so + * handlers reach the latest. A persistent (`duration: 0`) progress toast shows per image during the + * upload and is dismissed once it settles, when the upload hook's own "Uploaded"/"Failed" toast takes over. + */ const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => Promise.resolve() ) insertImagesRef.current = async (images, at) => { let position = at for (const image of images) { - // Persistent (`duration: 0`) progress toast, dismissed once the upload settles — the upload - // hook then shows its own "Uploaded"/"Failed" toast. const uploadingToastId = toast.info(`Uploading "${image.name}"…`, { duration: 0 }) const result = await uploadFile .mutateAsync({ workspaceId, file: image, folderId: file.folderId ?? null }) @@ -389,7 +390,6 @@ export function LoadedRichMarkdownEditor({ streamRafRef.current = requestAnimationFrame(tick) return } - // Drop a frame scheduled just before settle so it can't land afterward and clobber the final content. if (streamRafRef.current !== null) { cancelAnimationFrame(streamRafRef.current) streamRafRef.current = null @@ -399,7 +399,6 @@ export function LoadedRichMarkdownEditor({ if (isInitialSettle || wasStreamingRef.current) { wasStreamingRef.current = false settledRef.current = lockSettled(content) - // Re-seed only if the settled body differs from the last streamed chunk (avoids a needless doc rebuild + selection loss). const body = splitFrontmatter(content).body if (body !== lastSyncedBodyRef.current) { lastSyncedBodyRef.current = body From 653125c02e93521a530265cdc31240d9a9dcf66f Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 12:46:13 -0700 Subject: [PATCH 23/32] refactor(rich-editor): cleanup + simplify pass over the markdown editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete dead parseSimHref (+ barrel/test); mentions parse via the node tokenizer - Extract serializeMarkdownDocument — one canonical serialize pipeline shared by the dirty-check baseline and the round-trip-safety probe (was inlined in both) - Extract selectLeafAcross — shared tail of the two arrow leaf-selection handlers - Reset the suggestion active-row during render (prevX idiom) instead of an effect - Inline the skill modal's trivial hasChanges (drop the useMemo) - image.tsx: cn() over a template-literal className --- .../rich-markdown-editor/image.tsx | 6 ++++- .../rich-markdown-editor/keymap.ts | 27 ++++++++++--------- .../rich-markdown-editor/markdown-parse.ts | 15 +++++++++++ .../rich-markdown-editor/mention/index.ts | 2 +- .../mention/sim-link.test.ts | 15 +---------- .../rich-markdown-editor/mention/sim-link.ts | 9 ------- .../menus/use-suggestion-keyboard.ts | 17 +++++++----- .../rich-markdown-editor/normalize-content.ts | 10 ++----- .../rich-markdown-editor/round-trip-safety.ts | 26 +++++------------- .../components/skill-modal/skill-modal.tsx | 15 +++++------ 10 files changed, 62 insertions(+), 80 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index 5809af9625a..1f7cb78e49c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -3,6 +3,7 @@ import type { JSONContent } from '@tiptap/core' import { Image } from '@tiptap/extension-image' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { cn } from '@/lib/core/utils/cn' import { useFileContentSource } from '@/hooks/use-file-content-source' import { normalizeLinkHref } from './markdown-fidelity' @@ -225,7 +226,10 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN draggable={editable} data-drag-handle={editable ? '' : undefined} style={widthStyle} - className={`block max-w-full rounded-lg border border-[var(--border)]${editable ? ' cursor-grab' : ''}`} + className={cn( + 'block max-w-full rounded-lg border border-[var(--border)]', + editable && 'cursor-grab' + )} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index f9b930e12cd..f4625fec03e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -19,6 +19,19 @@ function isSuggestionMenuOpen(editor: Editor): boolean { ) } +/** + * Selects the leaf (divider/image) immediately across `boundary` in `direction`, or returns false if + * the neighbour isn't a selectable leaf — the shared tail of both arrow handlers below. + */ +function selectLeafAcross(editor: Editor, boundary: number, direction: 'up' | 'down'): boolean { + const resolved = editor.state.doc.resolve(boundary) + const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter + if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false + return editor.commands.setNodeSelection( + direction === 'up' ? boundary - adjacent.nodeSize : boundary + ) +} + /** * Arrowing off the edge of a textblock toward an adjacent divider or image selects that node * (a NodeSelection), giving keyboard parity with clicking it. Without this the gap cursor swallows @@ -29,12 +42,7 @@ function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean { if (!selection.empty || !editor.view.endOfTextblock(direction)) return false const { $from } = selection const boundary = direction === 'up' ? $from.before($from.depth) : $from.after($from.depth) - const resolved = editor.state.doc.resolve(boundary) - const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter - if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false - return editor.commands.setNodeSelection( - direction === 'up' ? boundary - adjacent.nodeSize : boundary - ) + return selectLeafAcross(editor, boundary, direction) } /** @@ -49,12 +57,7 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b return false } const boundary = direction === 'up' ? selection.from : selection.to - const resolved = editor.state.doc.resolve(boundary) - const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter - if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false - return editor.commands.setNodeSelection( - direction === 'up' ? boundary - adjacent.nodeSize : boundary - ) + return selectLeafAcross(editor, boundary, direction) } /** diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index 5428a123a3c..5417c0ee047 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -1,5 +1,10 @@ import { Editor, type JSONContent } from '@tiptap/core' import { createMarkdownContentExtensions } from './extensions' +import { + applyFrontmatter, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' /** * A single reused editor for chunked markdown parse/serialize, created lazily so importing this @@ -141,3 +146,13 @@ export function serializeMarkdownBody(body: string): string { editor.commands.setContent(parseMarkdownToDoc(body), { contentType: 'json' }) return editor.getMarkdown() } + +/** + * Serialize a full markdown document to the editor's canonical form: frontmatter is held aside and + * re-attached byte-exact while the body round-trips through {@link serializeMarkdownBody}. The single + * source of this pipeline (the dirty-check baseline and the round-trip-safety probe both use it). + */ +export function serializeMarkdownDocument(content: string): string { + const { frontmatter, body } = splitFrontmatter(content) + return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(serializeMarkdownBody(body))) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index c7c845382d6..1a90e5c594d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,6 +1,6 @@ export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention' export { MarkdownMention, MentionChip } from './mention-node' -export { parseSimHref, SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' +export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' export type { MentionItem, MentionKind } from './types' export { useEditorMentions } from './use-editor-mentions' export { useMarkdownMentions } from './use-markdown-mentions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts index 194e3bf6ba9..5efe8b66bd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts @@ -1,18 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseSimHref, simLinkPath } from './sim-link' - -describe('parseSimHref', () => { - it('parses a sim mention href', () => { - expect(parseSimHref('sim:file/abc-123')).toEqual({ kind: 'file', id: 'abc-123' }) - expect(parseSimHref('sim:knowledge/kb_1')).toEqual({ kind: 'knowledge', id: 'kb_1' }) - }) - - it('returns null for non-sim hrefs', () => { - expect(parseSimHref('https://sim.ai')).toBeNull() - expect(parseSimHref('sim:file')).toBeNull() - expect(parseSimHref('mailto:x@y.com')).toBeNull() - }) -}) +import { simLinkPath } from './sim-link' describe('simLinkPath', () => { const ws = 'ws1' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts index 9de4a299e74..5af0b6065e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts @@ -4,20 +4,11 @@ */ export const SIM_LINK_SCHEME = 'sim' -/** A bare `sim:/` mention href (the link target inserted by the `@` menu). */ -const SIM_HREF_PATTERN = /^sim:([a-z_]+)\/(.+)$/ - /** Builds the link target for a mention of `kind`/`id`. */ export function toSimHref(kind: string, id: string): string { return `${SIM_LINK_SCHEME}:${kind}/${id}` } -/** Parses a `sim:/` href into its parts, or `null` if it isn't a sim mention link. */ -export function parseSimHref(href: string): { kind: string; id: string } | null { - const match = href.match(SIM_HREF_PATTERN) - return match ? { kind: match[1], id: match[2] } : null -} - /** * Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable * destination. Each path matches the entity's real route: files open the file detail view, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts index 23cb3ba4912..ad283545d13 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts @@ -20,11 +20,12 @@ interface SuggestionKeyboard extends SuggestionKeyDownHandler { /** * Shared arrow/enter/tab navigation for the `/` and `@` suggestion lists. Owns the active-row state, - * resets it when the items change, scrolls the active row into view, and exposes an `onKeyDown` handle - * for the suggestion plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the - * chat composer). The handle is stable and reads live values through a ref, because the suggestion - * plugin captures it once via `ReactRenderer.ref` while the items may still be loading. Enter/Tab clamp - * the active index in case a filter shrank the list this frame before the active-index reset committed. + * resets it to the top during render when the item set changes (so the highlight is never briefly + * stale), scrolls the active row into view, and exposes an `onKeyDown` handle for the suggestion + * plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the chat composer). The + * handle is stable and reads live values through a ref, because the suggestion plugin captures it once + * via `ReactRenderer.ref` while the items may still be loading; Enter/Tab clamp the active index as a + * safety net. */ export function useSuggestionKeyboard( items: T[], @@ -33,9 +34,11 @@ export function useSuggestionKeyboard( ): SuggestionKeyboard { const [activeIndex, setActiveIndex] = useState(0) - useEffect(() => { + const [prevItems, setPrevItems] = useState(items) + if (items !== prevItems) { + setPrevItems(items) setActiveIndex(0) - }, [items]) + } useEffect(() => { containerRef.current diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts index c6f2e8f719f..d1ed029d351 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts @@ -1,9 +1,4 @@ -import { - applyFrontmatter, - postProcessSerializedMarkdown, - splitFrontmatter, -} from './markdown-fidelity' -import { serializeMarkdownBody } from './markdown-parse' +import { serializeMarkdownDocument } from './markdown-parse' import { isRoundTripSafe } from './round-trip-safety' /** @@ -18,6 +13,5 @@ import { isRoundTripSafe } from './round-trip-safety' */ export function normalizeMarkdownContent(raw: string): string { if (!isRoundTripSafe(raw)) return raw - const { frontmatter, body } = splitFrontmatter(raw) - return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(serializeMarkdownBody(body))) + return serializeMarkdownDocument(raw) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts index 3c3d35bd8de..a3d473b3193 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -1,9 +1,4 @@ -import { - applyFrontmatter, - postProcessSerializedMarkdown, - splitFrontmatter, -} from './markdown-fidelity' -import { serializeMarkdownBody } from './markdown-parse' +import { serializeMarkdownDocument } from './markdown-parse' /** * Above this size the file opens read-only. Parsing is chunked and linear now (see @@ -69,32 +64,25 @@ function linkedImageCount(content: string): number { return content.match(LINKED_IMAGE_PATTERN)?.length ?? 0 } -/** Serialize markdown through the exact editor pipeline (frontmatter held aside), chunked so the - * probe stays linear in document size. */ -function serialize(content: string): string { - const { frontmatter, body } = splitFrontmatter(content) - return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(serializeMarkdownBody(body))) -} - /** * Whether `content` survives the editor's markdown round-trip without data loss or autosave * churn. The editor opens the content read-only when this is false, so the probe is deliberately * conservative: it rejects on any doubt rather than risk an edit silently corrupting a file. * * Two complementary checks: known stable-loss constructs are matched directly (the idempotency - * probe is blind to them), and everything else must reach a fixpoint — `serialize(x)` twice in a - * row must be byte-identical, so the first edit can't churn the file. Lossless normalizations - * (`_`→`*`, setext→ATX, autolink→inline, loose→tight lists) reach a fixpoint after one pass and - * are allowed through; genuine churn (a blockquote wrapping a code fence keeps growing) is not. + * probe is blind to them), and everything else must reach a fixpoint — `serializeMarkdownDocument(x)` + * twice in a row must be byte-identical, so the first edit can't churn the file. Lossless + * normalizations (`_`→`*`, setext→ATX, autolink→inline, loose→tight lists) reach a fixpoint after one + * pass and are allowed through; genuine churn (a blockquote wrapping a code fence keeps growing) is not. */ export function isRoundTripSafe(content: string): boolean { if (content.length > PROBE_SIZE_LIMIT) return false const stripped = stripCode(content) if (STABLE_LOSS_PATTERNS.some((pattern) => pattern.test(stripped))) return false try { - const once = serialize(content) + const once = serializeMarkdownDocument(content) if (linkedImageCount(stripped) !== linkedImageCount(stripCode(once))) return false - return serialize(once) === once + return serializeMarkdownDocument(once) === once } catch { return false } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index bcf59014162..c7d8b6ce8a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useState } from 'react' import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import { @@ -89,14 +89,11 @@ export function SkillModal({ if (open !== prevOpen) setPrevOpen(open) if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues) - const hasChanges = useMemo(() => { - if (!initialValues) return true - return ( - name !== initialValues.name || - description !== initialValues.description || - content !== initialValues.content - ) - }, [name, description, content, initialValues]) + const hasChanges = + !initialValues || + name !== initialValues.name || + description !== initialValues.description || + content !== initialValues.content const handleSave = async () => { const newErrors: FieldErrors = {} From 223def580d44d9ba63a6043b573c4c012e36006d Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 12:57:40 -0700 Subject: [PATCH 24/32] refactor(rich-editor): share the suggestion-list shell and link-URL editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract SuggestionList: the grouped-list surface, empty state, listbox/option a11y structure, and active-row/hover/select wiring shared by the @ and / menus. Each menu keeps only its own grouping + itemKey/renderItem. - Extract link-editing (LinkUrlInput + applyLink): the inline link field and the normalize→extendMarkRange→set/unset commit logic, shared by the bubble menu and the link hover card. --- .../mention/mention-list.tsx | 77 ++++----------- .../menus/bubble-menu.tsx | 35 ++----- .../menus/link-editing.tsx | 53 ++++++++++ .../menus/link-hover-card.tsx | 35 ++----- .../menus/suggestion-list.tsx | 96 +++++++++++++++++++ .../slash-command/slash-command-list.tsx | 85 +++++----------- 6 files changed, 213 insertions(+), 168 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index e92d0b54b0b..1cd23d32216 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -1,11 +1,5 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from 'react' -import { cn } from '@/lib/core/utils/cn' -import { - SUGGESTION_GROUP_LABEL_CLASS, - SUGGESTION_ITEM_CLASS, - SUGGESTION_SCROLL_CLASS, - SUGGESTION_SURFACE_CLASS, -} from '../menus/suggestion-menu-chrome' +import { SuggestionList } from '../menus/suggestion-list' import { type SuggestionKeyDownHandler, useSuggestionKeyboard, @@ -82,55 +76,26 @@ export const MentionList = forwardRef(funct ) useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown]) - if (flat.length === 0) { - return ( -
-

- {rawItems.length === 0 ? 'Loading…' : 'No results'} -

-
- ) - } - return ( -
- {groups.map((group) => ( -
- - {group.items.map(({ item, index }) => { - const Icon = item.icon - return ( - - ) - })} -
- ))} -
+ `${item.kind}:${item.id}`} + renderItem={(item) => { + const Icon = item.icon + return ( + <> + {Icon && } + {item.label} + + ) + }} + /> ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index 10bee44d72b..0d4b1b397df 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -19,7 +19,7 @@ import { TextQuote, Unlink, } from 'lucide-react' -import { normalizeLinkHref } from '../markdown-fidelity' +import { applyLink, LinkUrlInput } from './link-editing' import { ToolbarButton, ToolbarDivider } from './toolbar-button' /** @@ -167,17 +167,12 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen } const commitLink = () => { - const href = normalizeLinkHref((linkValue ?? '').trim()) - const chain = selectCapturedRange(editor.chain().focus()) - chain.extendMarkRange('link') - if (href) chain.setLink({ href }) - else chain.unsetLink() - chain.run() + applyLink(selectCapturedRange(editor.chain().focus()), linkValue ?? '') setLinkValue(null) } const removeLink = () => { - selectCapturedRange(editor.chain().focus()).extendMarkRange('link').unsetLink().run() + applyLink(selectCapturedRange(editor.chain().focus()), '') setLinkValue(null) } @@ -227,24 +222,12 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen > {isEditingLink ? ( <> - setLinkValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - commitLink() - } else if (event.key === 'Escape') { - event.preventDefault() - setLinkValue(null) - } - }} - placeholder='Paste or type a link…' - className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]' + setLinkValue(null)} /> {active.link && ( void + onCommit: () => void + onCancel: () => void + inputRef: Ref +} + +/** + * The inline link-URL field shared by the bubble menu and the link hover card — Enter commits, Escape + * cancels. Styled to sit flush in the 28px floating micro-toolbar (a `ChipInput` would impose its own + * field chrome and break the bar), so this is a deliberate raw ``. + */ +export function LinkUrlInput({ value, onChange, onCommit, onCancel, inputRef }: LinkUrlInputProps) { + return ( + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + onCommit() + } else if (event.key === 'Escape') { + event.preventDefault() + onCancel() + } + }} + placeholder='Paste or type a link…' + className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx index 004c436e260..89e75b1b4dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx @@ -5,6 +5,7 @@ import type { Editor } from '@tiptap/react' import { Check, Copy, Pencil, Unlink } from 'lucide-react' import { createPortal } from 'react-dom' import { normalizeLinkHref } from '../markdown-fidelity' +import { applyLink, LinkUrlInput } from './link-editing' import { ToolbarButton } from './toolbar-button' interface LinkHoverCardProps { @@ -119,21 +120,13 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { const commitEdit = () => { const range = resolveLinkRange(editor, activeLink) - if (range) { - const href = normalizeLinkHref((draftHref ?? '').trim()) - const chain = editor.chain().focus().setTextSelection(range).extendMarkRange('link') - if (href) chain.setLink({ href }) - else chain.unsetLink() - chain.run() - } + if (range) applyLink(editor.chain().focus().setTextSelection(range), draftHref ?? '') dismiss() } const removeLink = () => { const range = resolveLinkRange(editor, activeLink) - if (range) { - editor.chain().focus().setTextSelection(range).extendMarkRange('link').unsetLink().run() - } + if (range) applyLink(editor.chain().focus().setTextSelection(range), '') dismiss() } @@ -156,24 +149,12 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { > {isEditing ? ( <> - setDraftHref(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - commitEdit() - } else if (event.key === 'Escape') { - event.preventDefault() - setDraftHref(null) - } - }} - placeholder='Paste or type a link…' - className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]' + onChange={setDraftHref} + onCommit={commitEdit} + onCancel={() => setDraftHref(null)} /> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx new file mode 100644 index 00000000000..be624920486 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx @@ -0,0 +1,96 @@ +import type { ReactNode, RefObject } from 'react' +import { cn } from '@/lib/core/utils/cn' +import { + SUGGESTION_GROUP_LABEL_CLASS, + SUGGESTION_ITEM_CLASS, + SUGGESTION_SCROLL_CLASS, + SUGGESTION_SURFACE_CLASS, +} from './suggestion-menu-chrome' + +/** A labeled run of items; `index` is each item's flat position, used for keyboard nav + scroll. */ +export interface SuggestionGroup { + group: string + items: { item: T; index: number }[] +} + +interface SuggestionListProps { + /** Scroll container ref, shared with the list's `useSuggestionKeyboard` for scroll-into-view. */ + containerRef: RefObject + groups: SuggestionGroup[] + activeIndex: number + setActiveIndex: (index: number) => void + /** Inserts the chosen item (the suggestion plugin's `command`). */ + command: (item: T) => void + ariaLabel: string + /** Prefix for each row's element id (`${idPrefix}-${index}`). */ + idPrefix: string + /** Shown in place of the list when there are no groups (e.g. "No results" / "Loading…"). */ + emptyLabel: string + itemKey: (item: T) => string + renderItem: (item: T) => ReactNode +} + +/** + * The shared grouped-list shell for the `/` and `@` suggestion menus: the bordered surface, the empty + * state, the `role="listbox"` → `role="group"` → option-button structure, and the active-row / hover / + * mousedown-select wiring. Each menu computes its own `groups` and supplies `itemKey`/`renderItem`; + * everything else (chrome, a11y, navigation hooks) lives here so the two menus stay identical. + */ +export function SuggestionList({ + containerRef, + groups, + activeIndex, + setActiveIndex, + command, + ariaLabel, + idPrefix, + emptyLabel, + itemKey, + renderItem, +}: SuggestionListProps) { + if (groups.length === 0) { + return ( +
+

{emptyLabel}

+
+ ) + } + + return ( +
+ {groups.map((group) => ( +
+ + {group.items.map(({ item, index }) => ( + + ))} +
+ ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index b7bdb66a8c9..4c0db5eca33 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -1,11 +1,5 @@ import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' -import { cn } from '@/lib/core/utils/cn' -import { - SUGGESTION_GROUP_LABEL_CLASS, - SUGGESTION_ITEM_CLASS, - SUGGESTION_SCROLL_CLASS, - SUGGESTION_SURFACE_CLASS, -} from '../menus/suggestion-menu-chrome' +import { SuggestionList } from '../menus/suggestion-list' import { type SuggestionKeyDownHandler, useSuggestionKeyboard, @@ -44,59 +38,32 @@ export const SlashCommandList = forwardRef -

No results

-
- ) - } - return ( -
- {groups.map((group) => ( -
- - {group.items.map(({ item, index }) => { - const Icon = item.icon - return ( - - ) - })} -
- ))} -
+ item.title} + renderItem={(item) => { + const Icon = item.icon + return ( + <> + + {item.title} + {item.shortcut && ( + + {item.shortcut} + + )} + + ) + }} + /> ) } ) From 298021c24d4f5de1adca64192acfe57be33b96f2 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 12:59:23 -0700 Subject: [PATCH 25/32] improvement(settings): align access-control detail UI + nav-driven docs link - Move permission-group Save/Discard into the detail header (matching secrets/whitelabeling) and delete the one-off sticky 'Unsaved changes' bar - Convert the Platform and Blocks config tabs to SettingsSection (drop the custom multi-column masonry + hand-rolled section labels); add an optional far-right action slot to SettingsSection for the per-section Select All - Replace the file-share auth-mode checkboxes with a multi-select ChipDropdown - Normalize per-tab spacing to gap-7, align the expand-chevron token to --text-icon, and match the list-row arrow size to the integrations precedent - Add a nav docsLink surfaced as a header 'Docs' ChipLink by SettingsPanel, wired for the six enterprise settings pages --- .../settings-panel/settings-panel.tsx | 15 +- .../settings-section/settings-section.tsx | 10 +- .../[workspaceId]/settings/navigation.ts | 12 +- .../components/access-control.tsx | 2 +- .../components/group-detail.tsx | 421 ++++++++---------- 5 files changed, 226 insertions(+), 234 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx index 0b38d3646db..64a22a15e05 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx @@ -1,7 +1,7 @@ 'use client' import { createContext, type ReactNode, useContext } from 'react' -import { ChipInput, Search } from '@/components/emcn' +import { ChipInput, ChipLink, Search } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getSettingsSectionMeta, @@ -47,6 +47,8 @@ interface SettingsPanelProps { title?: string /** Overrides the nav-driven description. */ description?: string + /** Overrides the nav-driven docs link (the "Docs" link rendered on the title row). */ + docsLink?: string /** Extra classes for the content column (layout/spacing only, e.g. a tighter `gap-*`). */ contentClassName?: string /** Ref forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */ @@ -73,6 +75,7 @@ export function SettingsPanel({ actions, title, description, + docsLink, contentClassName, scrollContainerRef, search, @@ -81,12 +84,20 @@ export function SettingsPanel({ const meta = section ? getSettingsSectionMeta(section) : null const resolvedTitle = title ?? meta?.label const resolvedDescription = description ?? meta?.description + const resolvedDocsLink = docsLink ?? meta?.docsLink return (
-
{actions}
+
+ {resolvedDocsLink && ( + + Docs + + )} + {actions} +
{label} {headerAccessory} + {action &&
{action}
}
{children} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index c9dfd848f1e..267741c1746 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -74,6 +74,8 @@ export interface NavigationItem { /** Hide for enterprise plans, which manage billing out-of-band. */ hideForEnterprise?: boolean externalUrl?: string + /** Absolute docs URL surfaced as a "Docs" link in the page header. */ + docsLink?: string } const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) @@ -114,6 +116,7 @@ export const allNavigationItems: NavigationItem[] = [ requiresHosted: true, requiresEnterprise: true, selfHostedOverride: isAccessControlEnabled, + docsLink: 'https://docs.sim.ai/platform/enterprise/access-control', }, { id: 'audit-logs', @@ -124,6 +127,7 @@ export const allNavigationItems: NavigationItem[] = [ requiresHosted: true, requiresEnterprise: true, selfHostedOverride: isAuditLogsEnabled, + docsLink: 'https://docs.sim.ai/platform/enterprise/audit-logs', }, { id: 'billing', @@ -239,6 +243,7 @@ export const allNavigationItems: NavigationItem[] = [ requiresHosted: true, requiresEnterprise: true, selfHostedOverride: isSSOEnabled, + docsLink: 'https://docs.sim.ai/platform/enterprise/sso', }, { id: 'data-retention', @@ -249,6 +254,7 @@ export const allNavigationItems: NavigationItem[] = [ requiresHosted: true, requiresEnterprise: true, selfHostedOverride: isDataRetentionEnabled, + docsLink: 'https://docs.sim.ai/platform/enterprise/data-retention', }, { id: 'data-drains', @@ -259,6 +265,7 @@ export const allNavigationItems: NavigationItem[] = [ requiresHosted: true, requiresEnterprise: true, selfHostedOverride: isDataDrainsEnabled, + docsLink: 'https://docs.sim.ai/platform/enterprise/data-drains', }, { id: 'whitelabeling', @@ -269,6 +276,7 @@ export const allNavigationItems: NavigationItem[] = [ requiresHosted: true, requiresEnterprise: true, selfHostedOverride: isWhitelabelingEnabled, + docsLink: 'https://docs.sim.ai/platform/enterprise/whitelabeling', }, { id: 'admin', @@ -295,7 +303,7 @@ export const allNavigationItems: NavigationItem[] = [ */ export function getSettingsSectionMeta( section: SettingsSection -): { label: string; description: string } | null { +): { label: string; description: string; docsLink?: string } | null { const item = allNavigationItems.find((navItem) => navItem.id === section) - return item ? { label: item.label, description: item.description } : null + return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null } diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 0af913eb34b..38304dd77b9 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -211,7 +211,7 @@ export function AccessControl() { }`}
- + ))}
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 498ff2b09be..2002dab83db 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -8,6 +8,7 @@ import { Checkbox, Chip, ChipConfirmModal, + ChipDropdown, ChipInput, ChipModal, ChipModalBody, @@ -432,7 +433,7 @@ function ProviderRow({ {isProviderAllowed && ( @@ -519,7 +520,7 @@ function BlockToolRow({ {isBlockAllowed && isExpandable && ( @@ -798,29 +799,25 @@ export function GroupDetail({ return categories }, [filteredPlatformFeatures]) - const platformCategoryColumns = useMemo(() => { - const categoryGroups = [ - ['Sidebar', 'Deploy Tabs', 'Collaboration'], - ['Workflow Panel', 'Tools', 'Features'], - ['Settings Tabs', 'Logs'], + const platformCategorySections = useMemo(() => { + const order = [ + 'Sidebar', + 'Deploy Tabs', + 'Collaboration', + 'Workflow Panel', + 'Tools', + 'Features', + 'Settings Tabs', + 'Logs', ] - - const assignedCategories = new Set(categoryGroups.flat()) - const unassigned = Object.keys(platformCategories).filter( - (c) => c !== 'Files' && !assignedCategories.has(c) + const known = order.filter((c) => platformCategories[c]?.length) + const extras = Object.keys(platformCategories).filter( + (c) => c !== 'Files' && !order.includes(c) && platformCategories[c]?.length ) - const groups = unassigned.length > 0 ? [...categoryGroups, unassigned] : categoryGroups - - return groups - .map((column) => - column - .map((category) => ({ - category, - features: platformCategories[category] ?? [], - })) - .filter((section) => section.features.length > 0) - ) - .filter((column) => column.length > 0) + return [...known, ...extras].map((category) => ({ + category, + features: platformCategories[category] ?? [], + })) }, [platformCategories]) const hasConfigChanges = useMemo(() => { @@ -1086,27 +1083,17 @@ export function GroupDetail({ return counts }, [editingConfig.deniedModels]) - const isFileShareAuthAllowed = useCallback( - (authType: ShareAuthType) => - editingConfig.allowedFileShareAuthTypes === null || - editingConfig.allowedFileShareAuthTypes.includes(authType), + const fileShareAuthValue = useMemo( + () => editingConfig.allowedFileShareAuthTypes ?? ALL_FILE_SHARE_AUTH_TYPES, [editingConfig.allowedFileShareAuthTypes] ) - const toggleFileShareAuthType = useCallback((authType: ShareAuthType) => { - setEditingConfig((prev) => { - const current = prev.allowedFileShareAuthTypes - const next = - current === null - ? ALL_FILE_SHARE_AUTH_TYPES.filter((t) => t !== authType) - : current.includes(authType) - ? current.filter((t) => t !== authType) - : [...current, authType] - return { - ...prev, - allowedFileShareAuthTypes: next.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : next, - } - }) + const setFileShareAuthTypes = useCallback((values: string[]) => { + setEditingConfig((prev) => ({ + ...prev, + allowedFileShareAuthTypes: + values.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : (values as ShareAuthType[]), + })) }, []) /** Persists the editing buffer. Returns whether the save succeeded so callers can decide whether to navigate away. */ @@ -1282,13 +1269,29 @@ export function GroupDetail({ Access Control - setShowDeleteConfirm(true)} - disabled={deletePermissionGroup.isPending} - > - {deletePermissionGroup.isPending ? 'Deleting...' : 'Delete'} - +
+ {hasConfigChanges && ( + <> + + Discard + + + {updatePermissionGroup.isPending ? 'Saving...' : 'Save'} + + + )} + setShowDeleteConfirm(true)} + disabled={deletePermissionGroup.isPending} + > + {deletePermissionGroup.isPending ? 'Deleting...' : 'Delete'} + +
@@ -1430,8 +1433,8 @@ export function GroupDetail({ )} {configTab === 'providers' && ( -
-
+
+
-
+
+
-
- {filteredCoreBlocks.length > 0 && ( -
-
- - Core Blocks - - - setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed) - } - > - {coreBlocksAllAllowed ? 'Deselect All' : 'Select All'} - -
-
- {filteredCoreBlocks.map((block) => { - const BlockIcon = block.icon - const checkboxId = `block-${block.type}` - return ( - + ) + })}
- )} - {filteredToolBlocks.length > 0 && ( -
-
-
- - Integrations and Triggers - - - Allow a whole integration with its checkbox, then expand it to deny - specific tools while keeping the rest available. - -
- - setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed) - } - > - {toolBlocksAllAllowed ? 'Deselect All' : 'Select All'} - -
-
- {filteredToolBlocks.map((block) => ( - toggleIntegration(block.type)} - deniedCount={deniedCountByBlock[block.type] ?? 0} - isAllowed={isToolAllowed} - onToggle={toggleTool} - onSetDenied={setToolsDenied} - /> - ))} -
+ + )} + {filteredToolBlocks.length > 0 && ( + + Allow a whole integration with its checkbox, then expand it to deny specific + tools while keeping the rest available. + + } + action={ + setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)} + > + {toolBlocksAllAllowed ? 'Deselect All' : 'Select All'} + + } + > +
+ {filteredToolBlocks.map((block) => ( + toggleIntegration(block.type)} + deniedCount={deniedCountByBlock[block.type] ?? 0} + isAllowed={isToolAllowed} + onToggle={toggleTool} + onSetDenied={setToolsDenied} + /> + ))}
- )} -
+ + )}
)} {configTab === 'platform' && ( -
-
+
+
-
- {platformCategoryColumns.map((column, columnIndex) => ( -
- {column.map(({ category, features }) => ( -
- - {category} - -
- {features.map((feature) => ( -
- - {feature.hint} -
- ))} -
+ {platformCategorySections.map(({ category, features }) => ( + +
+ {features.map((feature) => ( +
+ + {feature.hint}
))}
- ))} -
-
- Files - -
- - Auth modes public file-share links may use - -
- {FILE_SHARE_AUTH_TYPE_OPTIONS.map(({ value, label }) => ( - - ))} + + ))} + +
+ +
+ + Auth modes public file-share links may use + +
-
+
)}
- - {hasConfigChanges && ( -
- Unsaved changes - - Discard - - - {updatePermissionGroup.isPending ? 'Saving...' : 'Save'} - -
- )}
Date: Fri, 26 Jun 2026 13:05:35 -0700 Subject: [PATCH 26/32] feat(rich-editor): copy-link button shows a checkmark on copy Use the shared useCopyToClipboard hook so the link hover card's Copy button swaps to a Check for ~2s after copying, matching the rest of the platform. --- .../rich-markdown-editor/menus/link-hover-card.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx index 89e75b1b4dd..600f5b6c9ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx @@ -4,6 +4,7 @@ import { getMarkRange } from '@tiptap/core' import type { Editor } from '@tiptap/react' import { Check, Copy, Pencil, Unlink } from 'lucide-react' import { createPortal } from 'react-dom' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { normalizeLinkHref } from '../markdown-fidelity' import { applyLink, LinkUrlInput } from './link-editing' import { ToolbarButton } from './toolbar-button' @@ -46,6 +47,7 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { const isEditing = draftHref !== null const editInputRef = useRef(null) const floatingRef = useRef(null) + const { copied, copy } = useCopyToClipboard() const hideTimerRef = useRef(undefined) // Keep the card anchored to the hovered link with Floating UI's DOM core (the same primitive the @@ -175,7 +177,13 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { {rawHref} )} - copyToClipboard(rawHref)} /> + { + void copy(rawHref) + }} + /> {canEdit && } {canEdit && } @@ -184,7 +192,3 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { document.body ) } - -function copyToClipboard(text: string) { - if (text) void navigator.clipboard?.writeText(text).catch(() => {}) -} From b40bbd471b085482f4ca72635a49121b846765dd Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 13:14:41 -0700 Subject: [PATCH 27/32] fix(rich-editor): RichMarkdownField falls back to raw text for lossy markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the file editor's safety gate: decide once from the initial value via isRoundTripSafe — round-trip-safe content opens in the WYSIWYG editor, while lossy markdown (raw HTML, footnotes, comments) edits as raw text, so an edit can't silently drop those constructs. --- .../rich-markdown-field.tsx | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 3266bea9a7d..c614124207d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' -import { chipFieldSurfaceClass } from '@/components/emcn' +import { ChipTextarea, chipFieldSurfaceClass } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { createMarkdownEditorExtensions } from './extensions' import { @@ -16,6 +16,7 @@ import { useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' import { normalizeMarkdownContent } from './normalize-content' +import { isRoundTripSafe } from './round-trip-safety' import '@/components/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -47,11 +48,11 @@ interface RichMarkdownFieldProps { } /** - * A controlled, string-valued WYSIWYG markdown editor for modal fields — the file-less sibling of - * {@link RichMarkdownEditor}. It reuses the same TipTap extensions, parser, and menus but owns no file - * loading, autosave, or image upload. Drop it inside a `ChipModalField type='custom'`. + * The WYSIWYG editor for round-trip-safe content (chosen by {@link RichMarkdownField}). The file-less + * sibling of {@link RichMarkdownEditor}'s loaded editor: same TipTap extensions, parser, and menus but + * no file loading, autosave, or image upload. */ -export function RichMarkdownField({ +function LoadedRichMarkdownField({ value, onChange, placeholder = "Write something, or press '/' for commands…", @@ -188,3 +189,40 @@ export function RichMarkdownField({
) } + +/** + * Raw-text fallback for content the rich editor can't round-trip losslessly — editing the markdown + * source directly so an edit can't silently drop footnotes, raw HTML, or comments. + */ +function RawMarkdownField({ + value, + onChange, + placeholder, + disabled = false, + isStreaming = false, + minHeight = 140, + maxHeight = 360, + error = false, +}: RichMarkdownFieldProps) { + return ( + onChange(event.target.value)} + placeholder={placeholder} + error={error} + readOnly={disabled || isStreaming} + style={{ minHeight, maxHeight }} + /> + ) +} + +/** + * A controlled, string-valued markdown editor for modal fields. Drop it inside a `ChipModalField + * type='custom'`. Mirrors the file editor's safety gate (decided once from the initial value): + * round-trip-safe content opens in the WYSIWYG editor, while lossy markdown (raw HTML, footnotes, + * comments) falls back to raw-text editing so an edit can't silently drop those constructs. + */ +export function RichMarkdownField(props: RichMarkdownFieldProps) { + const [isSafe] = useState(() => isRoundTripSafe(props.value)) + return isSafe ? : +} From 31d1c924f597c934f475a6a9b233a177fbbe6273 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 16:00:22 -0700 Subject: [PATCH 28/32] =?UTF-8?q?feat(rich-editor):=20divider/leaf=20editi?= =?UTF-8?q?ng=20=E2=80=94=20backspace,=20select-all,=20gap=20cursor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backspace at the start of an empty block whose previous sibling is a divider/image removes the blank line (instead of deleting the leaf) and selects the divider above; a non-empty block selects the leaf so a second Backspace deletes it (highlight-before-delete). - Select-all (and any range selection) now visibly highlights dividers/images, which the native text highlight skips because leaves carry no text — via a decoration that paints a selection band. - The gap cursor between two adjacent leaves no longer draws its stray caret (matching Linear); the position stays functional. Leading/trailing gap cursors keep their caret. - Unit tests for the backspace + select-all behavior. --- .../rich-markdown-editor/keymap.test.ts | 83 +++++++++++++++++++ .../rich-markdown-editor/keymap.ts | 80 +++++++++++++++--- .../rich-markdown-editor.css | 28 +++++++ 3 files changed, 179 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 169a3cf360e..ca2d7ece5a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -7,6 +7,7 @@ * keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens. */ import { Editor } from '@tiptap/core' +import { AllSelection, NodeSelection } from '@tiptap/pm/state' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMarkdownEditorExtensions } from './extensions' import { MENTION_PLUGIN_KEY } from './mention' @@ -16,6 +17,28 @@ function editorWith(content: string): Editor { return new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content }) } +/** Block-type sequence of the top-level doc nodes. */ +function blockShape(editor: Editor): string[] { + const shape: string[] = [] + editor.state.doc.forEach((node) => shape.push(node.type.name)) + return shape +} + +/** Position of the first node of `type`, or -1. */ +function firstPosOf(editor: Editor, type: string): number { + let pos = -1 + editor.state.doc.descendants((node, p) => { + if (pos < 0 && node.type.name === type) pos = p + }) + return pos +} + +function pressBackspace(editor: Editor): void { + editor.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }) + ) +} + describe('suggestion-aware arrow keymap', () => { beforeEach(() => { // The suggestion render lifecycle uses these; jsdom lacks them. @@ -58,3 +81,63 @@ describe('suggestion-aware arrow keymap', () => { editor.destroy() }) }) + +describe('divider Backspace', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + it('removes the empty line between two dividers and selects the higher divider', () => { + const editor = editorWith('

before



after

') + editor.commands.focus() + let emptyPos = -1 + editor.state.doc.descendants((node, pos) => { + if (emptyPos < 0 && node.type.name === 'paragraph' && node.content.size === 0) + emptyPos = pos + 1 + }) + editor.commands.setTextSelection(emptyPos) + pressBackspace(editor) + + expect(blockShape(editor)).toEqual([ + 'paragraph', + 'horizontalRule', + 'horizontalRule', + 'paragraph', + ]) + const { selection } = editor.state + expect(selection instanceof NodeSelection).toBe(true) + // The selected divider is the higher (first) one, not the lower. + expect(selection.from).toBe(firstPosOf(editor, 'horizontalRule')) + editor.destroy() + }) + + it('selects the divider when Backspace is pressed at the start of a non-empty block below it', () => { + const editor = editorWith('

before


text

') + editor.commands.focus() + let textStart = -1 + editor.state.doc.descendants((node, pos) => { + if (textStart < 0 && node.type.name === 'paragraph' && node.textContent === 'text') + textStart = pos + 1 + }) + editor.commands.setTextSelection(textStart) + pressBackspace(editor) + + const { selection } = editor.state + expect(selection instanceof NodeSelection).toBe(true) + expect((selection as NodeSelection).node.type.name).toBe('horizontalRule') + // The block is untouched — the divider is only highlighted, not deleted. + expect(blockShape(editor)).toEqual(['paragraph', 'horizontalRule', 'paragraph']) + editor.destroy() + }) + + it('select-all spans the whole document, dividers included', () => { + const editor = editorWith('

a


b


c

') + editor.commands.selectAll() + + const { selection, doc } = editor.state + expect(selection instanceof AllSelection).toBe(true) + expect(selection.from).toBe(0) + expect(selection.to).toBe(doc.content.size) + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index f4625fec03e..2c51d43646f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -1,6 +1,8 @@ import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' -import { NodeSelection } from '@tiptap/pm/state' +import { GapCursor } from '@tiptap/pm/gapcursor' +import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' import { MENTION_PLUGIN_KEY } from './mention' import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' @@ -64,16 +66,23 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * Editor-specific keyboard behavior layered on top of StarterKit's defaults: * * - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or - * no-ops, stranding the heading style; a second Backspace then merges as usual); at the start of a - * block whose previous sibling is a horizontal rule it deletes the rule (ProseMirror's default - * `joinBackward` can't cross a leaf node, so without this pressing Backspace below a divider is a - * confusing no-op). + * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a + * block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` can't cross + * the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below dividers + * without touching the divider itself), while a *non-empty* block selects the leaf — so a first + * Backspace highlights what a second deletes, the same highlight-before-delete affordance as clicking + * it and parity with the arrow-key leaf selection. * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the * block is already fully selected) falls through to the default whole-document select-all, the * same scoped behavior as a code editor. * - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge * ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next * ({@link selectAdjacentSelectedLeaf}). + * + * Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all), + * which the browser's native text highlight skips because leaves carry no text, and (b) flags the + * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its + * otherwise-stray caret. */ export const RichMarkdownKeymap = Extension.create({ name: 'richMarkdownKeymap', @@ -84,16 +93,25 @@ export const RichMarkdownKeymap = Extension.create({ Backspace: ({ editor }) => { const { selection, doc } = editor.state if (!selection.empty || selection.$from.parentOffset !== 0) return false - if (selection.$from.parent.type.name === 'heading') { + const { $from } = selection + if ($from.parent.type.name === 'heading') { return editor.commands.setParagraph() } - const blockStart = selection.$from.before(selection.$from.depth) + const blockStart = $from.before($from.depth) const nodeBefore = doc.resolve(blockStart).nodeBefore - if (nodeBefore?.type.name !== 'horizontalRule') return false - return editor.commands.command(({ tr }) => { - tr.delete(blockStart - nodeBefore.nodeSize, blockStart) - return true - }) + if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false + const leafStart = blockStart - nodeBefore.nodeSize + if ($from.parent.isTextblock && $from.parent.content.size === 0) { + return editor.commands.command(({ tr, dispatch }) => { + if (dispatch) { + tr.delete(blockStart, $from.after($from.depth)) + tr.setSelection(NodeSelection.create(tr.doc, leafStart)) + dispatch(tr.scrollIntoView()) + } + return true + }) + } + return editor.commands.setNodeSelection(leafStart) }, 'Mod-a': ({ editor }) => { const { $from } = editor.state.selection @@ -111,4 +129,42 @@ export const RichMarkdownKeymap = Extension.create({ (selectAdjacentSelectedLeaf(editor, 'down') || selectAdjacentLeaf(editor, 'down')), } }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('richLeafSelectionHighlight'), + props: { + decorations(state) { + const { selection } = state + if (selection.empty || selection instanceof NodeSelection) return null + const decorations: Decoration[] = [] + state.doc.nodesBetween(selection.from, selection.to, (node, pos) => { + if (SELECTABLE_LEAVES.has(node.type.name)) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { class: 'rich-leaf-in-selection' }) + ) + } + }) + return decorations.length ? DecorationSet.create(state.doc, decorations) : null + }, + attributes(state): Record { + const { selection } = state + if (!(selection instanceof GapCursor)) return {} + const before = selection.$head.nodeBefore + const after = selection.$head.nodeAfter + if ( + before && + after && + SELECTABLE_LEAVES.has(before.type.name) && + SELECTABLE_LEAVES.has(after.type.name) + ) { + return { 'data-gap-between-leaves': 'true' } + } + return {} + }, + }, + }), + ] + }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 83fac9149aa..448583bcf9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -144,6 +144,14 @@ background-color: var(--text-primary); } +/* …except between two adjacent leaves (dividers/images), where that caret floats as a stray mark in + empty space the view gives no hint about. Hide it there (matching Linear): the gap cursor stays + functional — typing still inserts a block between them — it just isn't drawn. Leading/trailing gaps + keep theirs. The keymap plugin sets `data-gap-between-leaves` when both neighbours are leaves. */ +.rich-markdown-prose[data-gap-between-leaves] .ProseMirror-gapcursor::after { + display: none; +} + .rich-markdown-prose ul, .rich-markdown-prose ol { padding-left: 1.25em; @@ -290,6 +298,26 @@ margin: 1.5em 0; } +/* A divider/image swept into a range selection (e.g. select-all) — the browser's native ::selection + highlight skips leaf nodes (they hold no text), so the keymap's decoration paints the band itself. + A divider is a void
(no pseudo-elements), so a box-shadow spreads the selection band around its + hairline without shifting layout; an image rings instead. */ +.rich-markdown-prose hr.rich-leaf-in-selection { + box-shadow: 0 0 0 0.4em var(--selection-bg); + border-radius: 1px; +} + +.dark .rich-markdown-prose hr.rich-leaf-in-selection { + box-shadow: 0 0 0 0.4em var(--selection-dark); +} + +.rich-markdown-prose .rich-leaf-in-selection:has(img) img, +.rich-markdown-prose img.rich-leaf-in-selection { + outline: 2px solid var(--selection-bg); + outline-offset: 2px; + border-radius: 4px; +} + .rich-markdown-prose table { width: 100%; border-collapse: collapse; From 8c5cc5279828fe54d7ed52ba3900daa4cd16a4e1 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 16:39:37 -0700 Subject: [PATCH 29/32] refactor(rich-editor): decouple headless bundle, fix linked-image round-trip, a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split mention-node into the schema-only `MarkdownMention` (mention-node.ts, no React/registry) and the live `MentionChip` node view (mention-chip.tsx); move the live factory to editor-extensions.ts and inject node views via DI. The headless round-trip path (markdown-parse/normalize-content/round-trip-safety) no longer pulls the 269-block registry — it now bundles for the browser with zero node-builtin deps. - A sized + linked image serializes as `[![alt](src)](href)` (dropping the unrepresentable size) instead of `[](href)`, which the tokenizer can't reparse — the link is preserved, no silent data loss. Also escape the href title symmetrically. - Wire the suggestion menus as an ARIA combobox: while open, the editor gets aria-haspopup/expanded/controls and an aria-activedescendant tracking the active option, so screen readers announce it; cleared on close. Empty state is a role=status live region. --- .../dirty-on-open.test.ts | 2 +- .../rich-markdown-editor/editor-extensions.ts | 42 +++++++++++ .../rich-markdown-editor/extensions.ts | 66 ++++++----------- .../rich-markdown-editor/image.tsx | 11 ++- .../rich-markdown-editor/keymap.test.ts | 2 +- .../rich-markdown-editor/mention/index.ts | 3 +- .../mention/mention-chip.tsx | 62 ++++++++++++++++ .../mention/mention-list.test.tsx | 17 +++-- .../mention/mention-list.tsx | 6 +- .../{mention-node.tsx => mention-node.ts} | 72 ++----------------- .../rich-markdown-editor/mention/mention.ts | 1 + .../menus/suggestion-list.tsx | 47 ++++++++++-- .../rich-markdown-editor.tsx | 2 +- .../rich-markdown-field.tsx | 2 +- .../slash-command/slash-command-list.tsx | 6 +- .../slash-command/slash-command.ts | 1 + 16 files changed, 215 insertions(+), 127 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx rename apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/{mention-node.tsx => mention-node.ts} (52%) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts index 7c45efe601f..2c44021309c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts @@ -15,7 +15,7 @@ */ import { Editor } from '@tiptap/core' import { afterEach, beforeAll, describe, expect, it } from 'vitest' -import { createMarkdownEditorExtensions } from './extensions' +import { createMarkdownEditorExtensions } from './editor-extensions' import { applyFrontmatter, postProcessSerializedMarkdown, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts new file mode 100644 index 00000000000..675a1f487cc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -0,0 +1,42 @@ +import type { Extensions } from '@tiptap/core' +import Placeholder from '@tiptap/extension-placeholder' +import { CodeBlockWithLanguage } from './code-block' +import { CodeBlockHighlight } from './code-highlight' +import { createMarkdownContentExtensions } from './extensions' +import { ResizableImage } from './image' +import { RichMarkdownKeymap } from './keymap' +import { MarkdownPaste } from './markdown-paste' +import { Mention } from './mention/mention' +import { MentionChip } from './mention/mention-chip' +import { SlashCommand } from './slash-command/slash-command' + +interface MarkdownEditorExtensionOptions { + placeholder: string +} + +/** + * The full extension set for the live editor: the content extensions with their React node-view nodes + * injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions — + * `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu), + * `RichMarkdownKeymap`, `MarkdownPaste`, and `Placeholder`. + * + * Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls + * in for brand icons) stay out of the headless round-trip path, which only needs the schema. + */ +export function createMarkdownEditorExtensions({ + placeholder, +}: MarkdownEditorExtensionOptions): Extensions { + return [ + ...createMarkdownContentExtensions({ + codeBlock: CodeBlockWithLanguage, + image: ResizableImage, + mention: MentionChip, + }), + CodeBlockHighlight, + SlashCommand, + Mention, + RichMarkdownKeymap, + MarkdownPaste, + Placeholder.configure({ placeholder }), + ] +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index fa8917937a7..2595896ec89 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -1,7 +1,6 @@ -import type { Extensions, JSONContent, MarkdownRendererHelpers } from '@tiptap/core' +import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core' import { Code } from '@tiptap/extension-code' import { TaskItem, TaskList } from '@tiptap/extension-list' -import Placeholder from '@tiptap/extension-placeholder' import { renderTableToMarkdown, Table, @@ -11,14 +10,11 @@ import { } from '@tiptap/extension-table' import { Markdown } from '@tiptap/markdown' import StarterKit from '@tiptap/starter-kit' -import { CodeBlockWithLanguage, MarkdownCodeBlock } from './code-block' -import { CodeBlockHighlight } from './code-highlight' -import { MarkdownImage, ResizableImage } from './image' -import { RichMarkdownKeymap } from './keymap' +import { MarkdownCodeBlock } from './code-block' +import { MarkdownImage } from './image' import { MarkdownLinkInputRule } from './link-input-rule' -import { MarkdownPaste } from './markdown-paste' -import { MarkdownMention, Mention, MentionChip, SIM_LINK_SCHEME } from './mention' -import { SlashCommand } from './slash-command/slash-command' +import { MarkdownMention } from './mention/mention-node' +import { SIM_LINK_SCHEME } from './mention/sim-link' /** * The `@`-mention link scheme, registered on the Link mark — without it the schema strips the @@ -60,13 +56,16 @@ const PipeSafeTable = Table.extend({ .replace(/\n+$/, ''), }) -interface MarkdownEditorExtensionOptions { - placeholder: string -} - -interface ContentExtensionOptions { - /** Use the React node views (code-block language picker, image resize). Off for headless tests. */ - nodeViews?: boolean +/** + * Node-view variants the live editor injects in place of the headless defaults — the code-block + * language picker, the resizable image, and the mention chip. They pull React (and, for the mention + * chip, the block registry for brand icons), so the headless round-trip path omits them: passing + * nothing keeps {@link createMarkdownContentExtensions} free of React and the registry. + */ +export interface ContentNodeViews { + codeBlock?: Node + image?: Node + mention?: Node } /** @@ -75,13 +74,13 @@ interface ContentExtensionOptions { * Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add * `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown. * - * The code block is the standalone `CodeBlock` so the live editor can swap in a node view; - * the schema and markdown output are identical either way. + * Headless by default (the `nodeViews` overrides are empty), so importing this module — e.g. for the + * markdown round-trip in `markdown-parse.ts` — never constructs React node views or pulls the block + * registry. The live editor passes the node-view nodes via {@link createMarkdownEditorExtensions}; the + * schema and markdown output are identical either way. */ -export function createMarkdownContentExtensions({ - nodeViews = false, -}: ContentExtensionOptions = {}): Extensions { - const codeBlock = (nodeViews ? CodeBlockWithLanguage : MarkdownCodeBlock).configure({ +export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}): Extensions { + const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({ HTMLAttributes: { class: 'code-editor-theme' }, }) return [ @@ -93,8 +92,8 @@ export function createMarkdownContentExtensions({ }), InlineCode, codeBlock, - (nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }), - nodeViews ? MentionChip : MarkdownMention, + (nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }), + nodeViews.mention ?? MarkdownMention, TaskList, TaskItem.configure({ nested: true }), PipeSafeTable.configure({ resizable: true }), @@ -105,22 +104,3 @@ export function createMarkdownContentExtensions({ Markdown, ] } - -/** - * The full extension set for the live editor: the content extensions plus the UI-only - * extensions — `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), and - * `Placeholder`. - */ -export function createMarkdownEditorExtensions({ - placeholder, -}: MarkdownEditorExtensionOptions): Extensions { - return [ - ...createMarkdownContentExtensions({ nodeViews: true }), - CodeBlockHighlight, - SlashCommand, - Mention, - RichMarkdownKeymap, - MarkdownPaste, - Placeholder.configure({ placeholder }), - ] -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index 1f7cb78e49c..187d4c81f4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -33,6 +33,11 @@ function escapeAttr(value: string): string { * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to * preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is * wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`. + * + * A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer + * only recognizes `[![alt](src)](href)`, so emitting `[](href)` would silently drop the link on + * reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the + * unsized `[![alt](src)](href)` form — the link matters more than the exact dimensions for a badge. */ function imageMarkdown(node: JSONContent): string { const attrs = node.attrs ?? {} @@ -44,7 +49,7 @@ function imageMarkdown(node: JSONContent): string { const width = attrs.width const height = attrs.height let image: string - if (width || height) { + if ((width || height) && !href) { const parts = [`src="${escapeAttr(src)}"`] if (alt) parts.push(`alt="${escapeAttr(alt)}"`) if (title) parts.push(`title="${escapeAttr(title)}"`) @@ -59,7 +64,9 @@ function imageMarkdown(node: JSONContent): string { image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})` } if (!href) return image - const hrefTitlePart = hrefTitle ? ` "${hrefTitle}"` : '' + // Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the + // image title escaping above). + const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : '' return `[${image}](${href}${hrefTitlePart})` } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index ca2d7ece5a7..c7c8f425417 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -9,7 +9,7 @@ import { Editor } from '@tiptap/core' import { AllSelection, NodeSelection } from '@tiptap/pm/state' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createMarkdownEditorExtensions } from './extensions' +import { createMarkdownEditorExtensions } from './editor-extensions' import { MENTION_PLUGIN_KEY } from './mention' import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index 1a90e5c594d..1461b83b382 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,5 +1,6 @@ export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention' -export { MarkdownMention, MentionChip } from './mention-node' +export { MentionChip } from './mention-chip' +export { MarkdownMention } from './mention-node' export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' export type { MentionItem, MentionKind } from './types' export { useEditorMentions } from './use-editor-mentions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx new file mode 100644 index 00000000000..3ebff0132a3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx @@ -0,0 +1,62 @@ +import type { MouseEvent } from 'react' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { useParams, useRouter } from 'next/navigation' +import { cn } from '@/lib/core/utils/cn' +import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' +import { mentionIcon } from './mention-icon' +import { MarkdownMention, type MentionAttrs } from './mention-node' +import { simLinkPath } from './sim-link' + +/** + * Mirrors the home chat input's mention rendering (the textarea mirror overlay + * in `prompt-editor.tsx`): a borderless inline icon + label that flows with the + * surrounding prose — no pill background, no padding, normal weight, body text + * color, and a 12px icon. Integration icons keep their brand color via + * {@link getBareIconStyle} (see {@link MentionChipView}); other kinds stay + * monochrome through the `--text-icon` fallback below. + */ +const CHIP_CLASS = + 'mention-chip mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' + +/** + * Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted + * into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays + * inert so a click can't navigate away from an unsaved edit. This view pulls the block registry (for + * integration brand icons), so it's kept out of the headless {@link MarkdownMention} module. + */ +function MentionChipView({ node, editor }: ReactNodeViewProps) { + const router = useRouter() + const params = useParams() + const { kind, id, label } = node.attrs as MentionAttrs + const Icon = mentionIcon(kind, id) as StyleableIcon + const iconStyle = getBareIconStyle(Icon) + const navigable = editor.storage.mention?.navigable === true + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null + + const handleClick = (event: MouseEvent) => { + if (!path || !(event.metaKey || event.ctrlKey)) return + event.preventDefault() + router.push(path) + } + + return ( + + + {label} + + ) +} + +/** Live mention node with the chip view; same schema + markdown output as the headless one. */ +export const MentionChip = MarkdownMention.extend({ + addNodeView() { + return ReactNodeViewRenderer(MentionChipView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx index 739e6af9cfa..7197c455779 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -11,7 +11,7 @@ import { Editor } from '@tiptap/core' import { EditorContent, ReactRenderer } from '@tiptap/react' import { File } from 'lucide-react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMarkdownEditorExtensions } from '../extensions' +import { createMarkdownEditorExtensions } from '../editor-extensions' import { MentionList, type MentionListHandle } from './mention-list' import { createMentionStore } from './mention-store' import type { MentionItem } from './types' @@ -28,11 +28,13 @@ const tab = { event: new KeyboardEvent('keydown', { key: 'Tab' }) } describe('MentionList keyboard nav', () => { let container: HTMLElement let root: ReturnType + let editor: Editor beforeEach(async () => { // jsdom implements neither — both are exercised by scroll-into-view and ProseMirror. Element.prototype.scrollIntoView = vi.fn() document.elementFromPoint = vi.fn(() => null) + editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) const { createRoot } = await import('react-dom/client') container = document.createElement('div') document.body.appendChild(container) @@ -42,6 +44,7 @@ describe('MentionList keyboard nav', () => { afterEach(() => { act(() => root.unmount()) container.remove() + editor.destroy() }) it('navigates with arrows + inserts on enter once async items have loaded', () => { @@ -51,7 +54,9 @@ describe('MentionList keyboard nav', () => { // Menu opens before the workspace data resolves — the store is still empty. act(() => { - root.render() + root.render( + + ) }) expect(ref.current?.onKeyDown(arrowDown)).toBe(false) @@ -76,7 +81,9 @@ describe('MentionList keyboard nav', () => { const store = createMentionStore() act(() => { - root.render() + root.render( + + ) }) act(() => store.set(items)) @@ -89,7 +96,6 @@ describe('MentionList keyboard nav', () => { }) it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => { - const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) act(() => { root.render() }) @@ -98,7 +104,7 @@ describe('MentionList keyboard nav', () => { const store = createMentionStore() const renderer = new ReactRenderer(MentionList, { editor, - props: { query: '', command, store }, + props: { query: '', command, store, editor }, }) // Let the portal mount so ReactRenderer captures the imperative handle. await act(async () => {}) @@ -110,6 +116,5 @@ describe('MentionList keyboard nav', () => { expect(renderer.ref?.onKeyDown(arrowDown)).toBe(true) renderer.destroy() - editor.destroy() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 1cd23d32216..24787c11431 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -1,4 +1,5 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from 'react' +import type { Editor } from '@tiptap/core' import { SuggestionList } from '../menus/suggestion-list' import { type SuggestionKeyDownHandler, @@ -16,6 +17,8 @@ interface MentionListProps { command: (item: MentionItem) => void /** Live data source the host keeps populated. */ store: MentionStore + /** The editor, wired as the ARIA combobox while the menu is open. */ + editor: Editor } /** Per-group cap so a large workspace can't flood the menu; filtering still searches the full set. */ @@ -38,7 +41,7 @@ const GROUP_ORDER = [ * `useSyncExternalStore`) rather than props — so the list fills in as async workspace data lands. */ export const MentionList = forwardRef(function MentionList( - { query, command, store }, + { query, command, store, editor }, ref ) { const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) @@ -78,6 +81,7 @@ export const MentionList = forwardRef(funct return ( /)` markdown link — so the saved content is identical to a * plain link (agent-readable, round-trips through the chat's `chip-clipboard-codec`) while the editor - * shows it as a chip rather than a blue link. Shared by the headless round-trip path (no node view) - * and the live {@link MentionChip}, mirroring the image node's split. `renderText` emits the same - * portable link (an atom otherwise contributes no text), so copying a chip into a plain-text target — - * e.g. the chat composer — pastes back as a mention. + * shows it as a chip rather than a blue link. This module is schema + markdown only (no React, no icon + * registry) so the headless round-trip path stays light; the live {@link MentionChip} node view lives in + * `mention-chip.tsx`, mirroring the image node's split. `renderText` emits the same portable link (an + * atom otherwise contributes no text), so copying a chip into a plain-text target — e.g. the chat + * composer — pastes back as a mention. */ export const MarkdownMention = Node.create({ name: 'mention', @@ -110,55 +104,3 @@ export const MarkdownMention = Node.create({ return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, }) - -/** - * Mirrors the home chat input's mention rendering (the textarea mirror overlay - * in `prompt-editor.tsx`): a borderless inline icon + label that flows with the - * surrounding prose — no pill background, no padding, normal weight, body text - * color, and a 12px icon. Integration icons keep their brand color via - * {@link getBareIconStyle} (see {@link MentionChipView}); other kinds stay - * monochrome through the `--text-icon` fallback below. - */ -const CHIP_CLASS = - 'mention-chip mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' - -/** - * Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted - * into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays - * inert so a click can't navigate away from an unsaved edit. - */ -function MentionChipView({ node, editor }: ReactNodeViewProps) { - const router = useRouter() - const params = useParams() - const { kind, id, label } = node.attrs as MentionAttrs - const Icon = mentionIcon(kind, id) as StyleableIcon - const iconStyle = getBareIconStyle(Icon) - const navigable = editor.storage.mention?.navigable === true - const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined - const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null - - const handleClick = (event: MouseEvent) => { - if (!path || !(event.metaKey || event.ctrlKey)) return - event.preventDefault() - router.push(path) - } - - return ( - - - {label} - - ) -} - -/** Live mention node with the chip view; same schema + markdown output as the headless one. */ -export const MentionChip = MarkdownMention.extend({ - addNodeView() { - return ReactNodeViewRenderer(MentionChipView) - }, -}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index 6f7af09959e..bb4b621d78d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -79,6 +79,7 @@ export const Mention = Extension.create, MentionStorage>({ query: props.query, command: props.command, store: props.editor.storage.mention.store, + editor: props.editor, }), onOpen: (props) => props.editor.storage.mention?.onOpen?.(), }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx index be624920486..5a34b513ae4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx @@ -1,4 +1,5 @@ -import type { ReactNode, RefObject } from 'react' +import { type ReactNode, type RefObject, useEffect } from 'react' +import type { Editor } from '@tiptap/core' import { cn } from '@/lib/core/utils/cn' import { SUGGESTION_GROUP_LABEL_CLASS, @@ -14,6 +15,8 @@ export interface SuggestionGroup { } interface SuggestionListProps { + /** The editor whose contenteditable keeps focus while the menu is open — wired as the ARIA combobox. */ + editor: Editor /** Scroll container ref, shared with the list's `useSuggestionKeyboard` for scroll-into-view. */ containerRef: RefObject groups: SuggestionGroup[] @@ -22,7 +25,7 @@ interface SuggestionListProps { /** Inserts the chosen item (the suggestion plugin's `command`). */ command: (item: T) => void ariaLabel: string - /** Prefix for each row's element id (`${idPrefix}-${index}`). */ + /** Prefix for each row's element id (`${idPrefix}-${index}`) and the listbox id. */ idPrefix: string /** Shown in place of the list when there are no groups (e.g. "No results" / "Loading…"). */ emptyLabel: string @@ -35,8 +38,14 @@ interface SuggestionListProps { * state, the `role="listbox"` → `role="group"` → option-button structure, and the active-row / hover / * mousedown-select wiring. Each menu computes its own `groups` and supplies `itemKey`/`renderItem`; * everything else (chrome, a11y, navigation hooks) lives here so the two menus stay identical. + * + * Accessibility: focus stays in the editor's contenteditable while the user arrows the menu, so the + * editor is wired as the combobox — it gets `aria-haspopup`/`aria-expanded`/`aria-controls` and an + * `aria-activedescendant` pointing at the active option's id, the standard pattern for announcing the + * active row without moving focus. The attributes are removed when the menu closes (unmount). */ export function SuggestionList({ + editor, containerRef, groups, activeIndex, @@ -48,10 +57,39 @@ export function SuggestionList({ itemKey, renderItem, }: SuggestionListProps) { - if (groups.length === 0) { + const listboxId = `${idPrefix}-listbox` + const hasOptions = groups.length > 0 + const activeOptionId = hasOptions ? `${idPrefix}-${activeIndex}` : null + + useEffect(() => { + const dom = editor.view.dom + dom.setAttribute('aria-haspopup', 'listbox') + dom.setAttribute('aria-expanded', 'true') + return () => { + dom.removeAttribute('aria-haspopup') + dom.removeAttribute('aria-expanded') + dom.removeAttribute('aria-controls') + dom.removeAttribute('aria-activedescendant') + } + }, [editor]) + + useEffect(() => { + const dom = editor.view.dom + if (activeOptionId) { + dom.setAttribute('aria-controls', listboxId) + dom.setAttribute('aria-activedescendant', activeOptionId) + } else { + dom.removeAttribute('aria-controls') + dom.removeAttribute('aria-activedescendant') + } + }, [editor, listboxId, activeOptionId]) + + if (!hasOptions) { return (
-

{emptyLabel}

+

+ {emptyLabel} +

) } @@ -59,6 +97,7 @@ export function SuggestionList({ return (
void + /** The editor, wired as the ARIA combobox while the menu is open. */ + editor: Editor } /** @@ -19,7 +22,7 @@ interface SlashCommandListProps { * Exposes an imperative `onKeyDown` driven by the TipTap suggestion plugin. */ export const SlashCommandList = forwardRef( - function SlashCommandList({ items, command }, ref) { + function SlashCommandList({ items, command, editor }, ref) { const containerRef = useRef(null) const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard( items, @@ -40,6 +43,7 @@ export const SlashCommandList = forwardRef, SlashCommand mapProps: (props) => ({ items: props.items as SlashCommandItem[], command: props.command, + editor: props.editor, }), }), }), From d29e5e75f4832065f61ff4678dc043dbf701befd Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 16:49:43 -0700 Subject: [PATCH 30/32] fix(rich-editor): exempt an active @-mention query from the per-group cap The per-group MAX_PER_GROUP limit is meant to keep the unfiltered menu from flooding; applying it while a query is active hid matches past the eighth in a category, so search couldn't reach them. Cap only when there's no query. Adds a regression test (12 matches shown when searching). --- .../mention/mention-list.test.tsx | 23 +++++++++++++++++++ .../mention/mention-list.tsx | 12 ++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx index 7197c455779..36b5cf7c263 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -75,6 +75,29 @@ describe('MentionList keyboard nav', () => { expect(command).toHaveBeenCalledWith(items[1]) }) + it('an active query is exempt from the per-group cap (search reaches every match)', () => { + const ref = createRef() + const command = vi.fn() + const store = createMentionStore() + // 12 matches in one group — more than MAX_PER_GROUP (8). + const many: MentionItem[] = Array.from({ length: 12 }, (_, i) => ({ + kind: 'file', + id: `x${i}`, + label: `report-${i}`, + group: 'Files', + icon: File, + })) + + act(() => { + root.render( + + ) + }) + act(() => store.set(many)) + + expect(container.querySelectorAll('[role="option"]').length).toBe(12) + }) + it('accepts the active item on Tab, like Enter', () => { const ref = createRef() const command = vi.fn() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 24787c11431..f94a9b7498f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -21,7 +21,8 @@ interface MentionListProps { editor: Editor } -/** Per-group cap so a large workspace can't flood the menu; filtering still searches the full set. */ +/** Per-group cap so a large workspace can't flood the *unfiltered* menu; an active query is exempt so + * search reaches every match, not just the first eight in a category. */ const MAX_PER_GROUP = 8 /** Category heading order in the menu. */ @@ -48,9 +49,10 @@ export const MentionList = forwardRef(funct const containerRef = useRef(null) /** - * Filtered, group-capped, flattened in category order; `index` is the flat position for nav. A single - * pass over the full set filters by label and buckets by group (capped), then reads the buckets in - * category order — avoiding a separate filter pass per group. + * Filtered, flattened in category order; `index` is the flat position for nav. A single pass over the + * full set filters by label and buckets by group, then reads the buckets in category order — avoiding + * a separate filter pass per group. The per-group cap applies only to the unfiltered menu; once a + * query is active every match is shown so search can reach items past the eighth in a category. */ const { flat, groups } = useMemo(() => { const q = query.trim().toLowerCase() @@ -59,7 +61,7 @@ export const MentionList = forwardRef(funct if (q && !item.label.toLowerCase().includes(q)) continue const bucket = byGroup.get(item.group) if (!bucket) byGroup.set(item.group, [item]) - else if (bucket.length < MAX_PER_GROUP) bucket.push(item) + else if (q || bucket.length < MAX_PER_GROUP) bucket.push(item) } const ordered: { group: string; items: { item: MentionItem; index: number }[] }[] = [] From 8ccc309692dc4c224e4f87d75d1b65fa12aa1d1b Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 17:10:29 -0700 Subject: [PATCH 31/32] fix(rich-editor): mention icon fallback + typed sim-link input rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mentionIcon never returns undefined: an empty/unrecognized kind (schema default '', or a future kind on a sim: link) falls back to a generic icon instead of crashing the chip's render. Adds tests. - Add a mention input rule so typing `[label](sim:kind/id)` becomes a chip on the closing paren — matching the paste/load path (the tokenizer), which previously left typed syntax as literal text. A plain InputRule (full-range replace) is used; nodeInputRule would keep the surrounding brackets. --- .../mention/mention-icon.test.ts | 17 +++++++++++ .../mention/mention-icon.ts | 8 ++++-- .../mention/mention-node.ts | 28 ++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts new file mode 100644 index 00000000000..de6f15e6136 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts @@ -0,0 +1,17 @@ +/** @vitest-environment node */ +import { Box, File } from 'lucide-react' +import { describe, expect, it } from 'vitest' +import { mentionIcon } from './mention-icon' +import type { MentionKind } from './types' + +describe('mentionIcon', () => { + it('returns the category icon for a known kind', () => { + expect(mentionIcon('file', 'x')).toBe(File) + }) + + it('falls back to a generic icon for an empty or unrecognized kind (never undefined)', () => { + // The schema default is '' and a sim: link could carry a future kind — neither may crash render. + expect(mentionIcon('' as unknown as MentionKind, 'x')).toBe(Box) + expect(mentionIcon('dataset' as unknown as MentionKind, 'x')).toBe(Box) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts index ff2f64c6023..c655ed0f74d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts @@ -17,11 +17,13 @@ const KIND_ICONS: Record, MentionIcon> = { /** * Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by - * blockType, which is the mention `id`), falling back to a generic icon if the block was since removed - * so the chip is never icon-less; every other kind uses a lucide category icon. Shared by the menu + * blockType, which is the mention `id`), falling back to a generic icon if the block was since removed; + * every other kind uses a lucide category icon, falling back to the same generic icon for an empty or + * unrecognized kind (the schema default is `''`, and a `sim:` link could carry a kind a future version + * adds) — so the result is always a real component and the chip is never icon-less. Shared by the menu * rows and the inserted chip so both render the same icon. */ export function mentionIcon(kind: MentionKind, id: string): MentionIcon { if (kind === 'integration') return (getBlock(id)?.icon as MentionIcon | undefined) ?? Box - return KIND_ICONS[kind] + return KIND_ICONS[kind] ?? Box } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts index 6554bb3a920..af63ff8e40f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts @@ -1,5 +1,5 @@ import type { JSONContent, MarkdownToken } from '@tiptap/core' -import { Node } from '@tiptap/core' +import { InputRule, Node } from '@tiptap/core' import { toSimHref } from './sim-link' import type { MentionKind } from './types' @@ -103,4 +103,30 @@ export const MarkdownMention = Node.create({ const { kind, id, label } = node.attrs as MentionAttrs return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, + + /** + * Typing the portable `[label](sim:/)` syntax inline turns it into a chip on the closing + * paren — so live typing matches the paste/load path (which converts it via the tokenizer above). The + * rule lives on this node, which sits before {@link MarkdownLinkInputRule} in the extension list, so it + * claims the `sim:` form first; every other `[text](url)` falls through to the link rule untouched. + */ + addInputRules() { + const type = this.type + return [ + new InputRule({ + find: /\[((?:\\.|[^\]\\])+)\]\(sim:([a-z_]+)\/([^)\s]+)\)$/, + handler: ({ state, range, match }) => { + const [, rawLabel, kind, id] = match + if (!kind || !id) return null + // Replace the whole `[label](sim:…)` match with the chip (nodeInputRule would keep the + // surrounding brackets, as it only swaps the first capture group). + state.tr.replaceWith( + range.from, + range.to, + type.create({ kind, id, label: unescapeLabel(rawLabel ?? '') }) + ) + }, + }), + ] + }, }) From bda940042f0da132df12d4a0c873ed5b8e4a1baf Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Jun 2026 17:29:09 -0700 Subject: [PATCH 32/32] fix(rich-editor): raw-fallback paste hook + bound the filtered mention list - RawMarkdownField now honors onPasteText (e.g. skill SKILL.md destructuring), so a full-document paste is intercepted in the raw fallback too, not only the WYSIWYG path. - Bound the @-mention list while filtering (MAX_WHEN_FILTERED) so lifting the per-group cap for search can't render thousands of rows in the non-virtualized menu on a broad query; search still reaches deep matches well before the bound. Adds a test. - Tighten an extensions.ts doc comment (the headless path omits the registry + node-view construction, not React itself). --- .../rich-markdown-editor/extensions.ts | 6 ++--- .../mention/mention-list.test.tsx | 23 +++++++++++++++++++ .../mention/mention-list.tsx | 18 +++++++++++---- .../rich-markdown-field.tsx | 9 +++++++- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 2595896ec89..c99abf36c1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -58,9 +58,9 @@ const PipeSafeTable = Table.extend({ /** * Node-view variants the live editor injects in place of the headless defaults — the code-block - * language picker, the resizable image, and the mention chip. They pull React (and, for the mention - * chip, the block registry for brand icons), so the headless round-trip path omits them: passing - * nothing keeps {@link createMarkdownContentExtensions} free of React and the registry. + * language picker, the resizable image, and the mention chip. The mention chip pulls the block registry + * (for brand icons), so the headless round-trip path omits it: passing nothing keeps + * {@link createMarkdownContentExtensions} free of the registry and constructs no React node views. */ export interface ContentNodeViews { codeBlock?: Node diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx index 36b5cf7c263..2f5241d11ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -98,6 +98,29 @@ describe('MentionList keyboard nav', () => { expect(container.querySelectorAll('[role="option"]').length).toBe(12) }) + it('bounds the filtered list so a broad query cannot flood the menu', () => { + const ref = createRef() + const command = vi.fn() + const store = createMentionStore() + // 200 matches — far beyond any reasonable render; the list must cap the total. + const flood: MentionItem[] = Array.from({ length: 200 }, (_, i) => ({ + kind: 'file', + id: `f${i}`, + label: `alpha-${i}`, + group: 'Files', + icon: File, + })) + + act(() => { + root.render( + + ) + }) + act(() => store.set(flood)) + + expect(container.querySelectorAll('[role="option"]').length).toBe(50) + }) + it('accepts the active item on Tab, like Enter', () => { const ref = createRef() const command = vi.fn() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index f94a9b7498f..618560c3f2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -25,6 +25,11 @@ interface MentionListProps { * search reaches every match, not just the first eight in a category. */ const MAX_PER_GROUP = 8 +/** Total cap while filtering: lifts the per-group limit so search reaches deep matches, but still bounds + * the (non-virtualized) list so a broad single-char query on a huge workspace can't render thousands of + * rows. Generous enough that a real search is never truncated before the user narrows further. */ +const MAX_WHEN_FILTERED = 50 + /** Category heading order in the menu. */ const GROUP_ORDER = [ 'Files', @@ -51,17 +56,22 @@ export const MentionList = forwardRef(funct /** * Filtered, flattened in category order; `index` is the flat position for nav. A single pass over the * full set filters by label and buckets by group, then reads the buckets in category order — avoiding - * a separate filter pass per group. The per-group cap applies only to the unfiltered menu; once a - * query is active every match is shown so search can reach items past the eighth in a category. + * a separate filter pass per group. Without a query each group is capped ({@link MAX_PER_GROUP}); with + * a query the per-group cap is lifted (so search reaches deep matches) but the total is bounded + * ({@link MAX_WHEN_FILTERED}) so a broad query can't flood the non-virtualized list. */ const { flat, groups } = useMemo(() => { const q = query.trim().toLowerCase() const byGroup = new Map() + let shown = 0 for (const item of rawItems) { if (q && !item.label.toLowerCase().includes(q)) continue + if (q && shown >= MAX_WHEN_FILTERED) break const bucket = byGroup.get(item.group) - if (!bucket) byGroup.set(item.group, [item]) - else if (q || bucket.length < MAX_PER_GROUP) bucket.push(item) + if (!q && bucket && bucket.length >= MAX_PER_GROUP) continue + if (bucket) bucket.push(item) + else byGroup.set(item.group, [item]) + shown++ } const ordered: { group: string; items: { item: MentionItem; index: number }[] }[] = [] diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 87e139e478c..a109a4cb2ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -192,7 +192,9 @@ function LoadedRichMarkdownField({ /** * Raw-text fallback for content the rich editor can't round-trip losslessly — editing the markdown - * source directly so an edit can't silently drop footnotes, raw HTML, or comments. + * source directly so an edit can't silently drop footnotes, raw HTML, or comments. Honors the same + * `onPasteText` hook as the WYSIWYG path (e.g. skill `SKILL.md` destructuring) so a full-document paste + * is intercepted here too. */ function RawMarkdownField({ value, @@ -203,11 +205,16 @@ function RawMarkdownField({ minHeight = 140, maxHeight = 360, error = false, + onPasteText, }: RichMarkdownFieldProps) { return ( onChange(event.target.value)} + onPaste={(event) => { + const text = event.clipboardData.getData('text/plain') + if (text && onPasteText?.(text)) event.preventDefault() + }} placeholder={placeholder} error={error} readOnly={disabled || isStreaming}