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..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,8 +17,16 @@ import { MarkdownImage, ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' 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' +/** + * 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, @@ -86,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 }), @@ -109,6 +118,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/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 new file mode 100644 index 00000000000..c7c845382d6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -0,0 +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 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-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-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..739e6af9cfa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -0,0 +1,115 @@ +/** + * @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' }) } +const tab = { event: new KeyboardEvent('keydown', { key: 'Tab' }) } + +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('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(() => { + 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 new file mode 100644 index 00000000000..5049e64769d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -0,0 +1,134 @@ +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 { + type SuggestionKeyDownHandler, + useSuggestionKeyboard, +} from '../menus/use-suggestion-keyboard' +import type { MentionStore } from './mention-store' +import type { MentionItem } from './types' + +export type MentionListHandle = SuggestionKeyDownHandler + +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 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]) + + const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard( + flat, + command, + containerRef + ) + 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 ( + + ) + })} +
+ ))} +
+ ) +}) 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..ec6447ff88b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts @@ -0,0 +1,51 @@ +/** + * @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('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 new file mode 100644 index 00000000000..f7df2be49ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -0,0 +1,147 @@ +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 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. 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 { + 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: unescapeLabel(label ?? '') }, + } + }, + renderMarkdown: (node: JSONContent): string => { + const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs + 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 = + '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) { + 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} + + ) +} + +/** 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-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..f2b69e40799 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -0,0 +1,87 @@ +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 type { MentionItem } from './types' + +/** 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 + * 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 }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent([ + { type: 'mention', attrs: { kind: props.kind, id: props.id, label: props.label } }, + { 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..023faacc43c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -0,0 +1,113 @@ +import { useMemo } from '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 { mentionIcon } from './mention-icon' +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) + // 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 ?? '' + + 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: mentionIcon('integration', integration.blockType), + })) + }, [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: mentionIcon('file', file.id), + }) + for (const folder of folders.data ?? []) + items.push({ + kind: 'folder', + id: folder.id, + label: folder.name, + group: 'Folders', + icon: mentionIcon('folder', folder.id), + }) + for (const table of tables.data ?? []) + 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: mentionIcon('knowledge', kb.id), + }) + for (const workflow of workflows.data ?? []) + items.push({ + kind: 'workflow', + id: workflow.id, + label: workflow.name, + group: 'Workflows', + icon: mentionIcon('workflow', workflow.id), + }) + for (const skill of skills.data ?? []) + items.push({ + kind: 'skill', + id: skill.id, + label: skill.name, + group: 'Skills', + icon: mentionIcon('skill', skill.id), + }) + 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..c468042a18d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -0,0 +1,23 @@ +/** + * 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, width-capped like the chat mention menu. */ +export const SUGGESTION_SURFACE_CLASS = + '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 + * — 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 = + '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..7457ef517e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts @@ -0,0 +1,103 @@ +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) + // 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() } + 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/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/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..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,8 +21,10 @@ import { 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 { 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 @@ -311,6 +314,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..bb497f7500a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -0,0 +1,172 @@ +'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 { normalizeMarkdownContent } from './normalize-content' +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 + + // 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)) + + 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 + const serialized = applyFrontmatter(frontmatterRef.current, md) + onChangeRef.current(serialized === canonicalSeed ? initialValueRef.current : serialized) + }, + }) + + // 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/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-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index d9f10a7e8db..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,22 +1,24 @@ -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, + SUGGESTION_ITEM_CLASS, + 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[] 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. @@ -24,39 +26,13 @@ const ITEM_CLASS = */ 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]) - - useImperativeHandle(ref, () => ({ - 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 - }, - })) + const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard( + items, + command, + containerRef + ) + useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown]) const groups = useMemo(() => { const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = [] @@ -70,7 +46,7 @@ export const SlashCommandList = forwardRef +

No results

) @@ -81,17 +57,11 @@ export const SlashCommandList = forwardRef {groups.map((group) => (
- {group.items.map(({ item, index }) => { @@ -104,7 +74,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..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,15 +1,14 @@ -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 { PluginKey } from '@tiptap/pm/state' +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,71 +16,8 @@ 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, - } -} +/** 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 @@ -98,6 +34,7 @@ export const SlashCommand = Extension.create, SlashCommand return [ Suggestion({ editor: this.editor, + pluginKey: SLASH_COMMAND_PLUGIN_KEY, char: '/', allowSpaces: false, startOfLine: false, @@ -114,12 +51,23 @@ 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) }, - 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..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 @@ -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 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) const [activeTab, setActiveTab] = useState('create') @@ -62,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) @@ -126,16 +147,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 +229,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..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 @@ -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,28 @@ 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: () =>
, + } +) + +/** 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 onOpenChange: (open: boolean) => void @@ -32,6 +50,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 +62,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 +92,7 @@ export function VersionDescriptionModal({ } const handleSave = () => { - if (!workflowId) return + if (!workflowId || isTooLong) return updateMutation.mutate( { @@ -96,21 +118,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`} - /> + > + MAX_DESCRIPTION_LENGTH} + workspaceId={workspaceId} + /> + {updateMutation.error?.message || generateMutation.error?.message} @@ -128,7 +155,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..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 @@ -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' @@ -77,12 +78,14 @@ export function Versions({ 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) + // 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) { @@ -261,11 +264,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 +372,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'} 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(