From 245ad1bd462318d83e7336a40716fd17dc7bf725 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 10 Aug 2026 19:21:32 -0700 Subject: [PATCH] feat(workflows): new workflow block card, progress indicator, colors, dsl for natural language preview, retry configs (#6458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(workflow): refine canvas interactions and rendering * fix(workflow): keep outputs on the right, focus newly created blocks Connection anchors: an output now always leaves a card from the right. The cursor swell lets a drag start on any edge, but the left side is the input, so anchoring an outgoing edge there drew a line out of the input port and read as a second input. `normalizeCursorSourceHandleId` resolves every drag to the right anchor, `normalizePositionedSourceHandleId` collapses `source-left` alongside the legacy vertical anchors (so data from the API, an older client, or a stale save self-heals on load), and only the right-side source anchor is mounted. Drops in `onConnectEnd` are always source -> target. The branch that reversed the edge for a drag starting on an input could never run: the `target` handle is `isConnectableStart={false}` and the positioned side anchors are `isConnectable={false}`, so React Flow never reports an input as a drag origin. Removed it and its now-unused imports. A newly created block is centered once its node mounts and is measured, so a card added from a drag-release, the block menu, or the toolbar is never left off-screen or under the editor panel. The editor panel's block icon uses the same type accent as the card's badge instead of the block's legacy `bgColor`, which had left the panel on the old per-integration brand colours. Co-Authored-By: Claude Opus 5 * fix(workflow): floor header-only card height, adopt brand tag palette The Start card intermittently collapsed after load, squashing the action-menu tab so its icon row sat over the card. `.workflow-drag-handle` is the host the border renderer measures, and both it and the header row took their height from `blockHeight && blockHeight > 0`. `blockHeight` comes from the deterministic-dimensions pass and is already floored at MIN_PAINTED_HEIGHT (48), but it is absent on the first frames — and with no floor the host collapsed to its natural content height (25.5px for a header-only trigger, exactly the title's line box). The border builds its perimeter from `host.offsetHeight`, so that window painted a sub-floor card: too little straight edge remained on the vertical runs for the action-menu tab, which collapsed into the corner arcs. Whether you saw it depended purely on whether the dimension publish had landed, which is why it reproduced on one workflow and not another. Floor all three: the host, the header row (so `items-center` centres the title and type tag rather than pinning them to the top), and the border's own `offsetHeight` read. Also raise ACTION_MENU_CONTENT_READY_THRESHOLD to 0.9. At 0.8 the 24px icon row was revealed while the swell had only reached 22.4px of its 28px — shorter than the row it contains. Secondary to the above, but a real overflow window on its own. The test now pins the ratio rather than the constant. Tag palette moves to fixed brand values (hex, not derived oklch) with two inks — #F8F8F8 on dark fills, #1A1A1A on light. Tones are renamed to match what they render. `green` (2.55:1) and `orange` (3.15:1) sit under WCAG AA against their paired ink; both are deliberate brand decisions and are documented in the component. Deploy and Run take two new Button variants rather than className overrides, so `tertiary` stays green everywhere else. Co-Authored-By: Claude Opus 5 * improvement(workflow): polish workflow canvas interactions * fix(workflow): restyle loop drop target outline * fix(workflow): shorten human block catalog label * fix(workflow): canonicalize realtime edge handles * code cleanup * sizing fixes * improvement(blocks): sentencify every block * change tebse * improvement(workflow): land notes UI and execution progress, consolidate duplicates Ports the notes canvas editing and execution progress-indicator work, then removes the parallel paths it arrived with so each concern has one owner. Fixes found while consolidating: - Note height was measured while the card was expanded to NOTE_EXPANDED_WIDTH, where text re-wraps shorter, and published as the node's compact height. The collapse then animated to a height never measured at the compact width. - The edge pulse glow filter used the default objectBoundingBox units, so a straight horizontal edge — what an auto-laid-out chain produces — resolved the filter region to zero height and stopped the glow rendering entirely. - A subflow's inner Start pill still read isNodeSelected while its border read usesSelectedVisuals, so the two disagreed during execution. - The Run/Stop button's disabled prop gated only Run while its handler cancelled unconditionally, offering a Stop the cancel route answers with 403. Consolidated: - One note editor. The view's built-in textarea was unreachable in production (the app always injects the markdown editor) and was kept alive only by tests asserting against it; renderContentEditor is now required. - onBlur/onCancel collapse to onEndEditing — content persists per keystroke, so there was never a draft for a cancel path to discard. - DEFAULT_NOTE_COLOR, the note height bounds, the note content reader and the card width each had two or three definitions; each now has one. - Removed with zero consumers: graphite/graphiteSubtle button variants, data-subflow-selected, inputPlaceholderClassName, an effect that could never fire, and getNoteColorOption's unreachable fallbacks. Restores the role='status' announcement the progress rewrite dropped, and hardens isNoteColor against inherited Object keys. Co-Authored-By: Claude * improvement(workflow): reuse the platform markdown editor in notes Notes carried their own TipTap wiring — a second markdown editor that reimplemented, more thinly, what `RichMarkdownField` already does for the skill modal, skill fields and the deploy version description. It is now a ~20 line skin: the Note supplies its type scale and per-colour selection tint, and the field supplies the extension set, frontmatter held out-of-band, the round-trip safety gate and its raw-source fallback, and markdown paste. `RichMarkdownField` gains two additive props, both defaulting to today's behaviour so the file editor is untouched: `surface` ('field' | 'bare') and `proseClassName`. All three existing consumers pass an explicit `minHeight` and no `surface`, so they take the original path unchanged. Exiting the note editor moved to the card, because the editor's `/` and `@` menus consume Escape to close themselves and ProseMirror checks `editorProps` before plugin handlers — intercepting it inside the editor would have broken both menus. The card now honours Escape only when nothing already consumed it, which also let `onEndEditing` leave the injection contract. The note editor is lazy now, matching every other consumer: it was pulling TipTap and the full extension set into the canvas's initial chunk. Also: - One `areRunFromBlockDependenciesSatisfied`. The ActionBar, the canvas context menu and the run-from-block handler each carried a byte-identical copy, and the handler expressed the snapshot requirement differently, so the affordance and the action could disagree. Each copy also re-scanned `edges` once per incoming edge, on every ActionBar on the canvas. - Reduced motion is one `usePrefersReducedMotion` in @sim/emcn rather than a sixth ad-hoc `matchMedia`. The edge pulse now stops rendering instead of hiding: `motion-reduce:hidden` is `display: none`, which left four SMIL timelines running per edge. - The pulse glow bleed covers the canvas minimum zoom. The strokes are `non-scaling-stroke`, so the 6px tail spans 3/zoom user units — 30 at 0.1. Co-Authored-By: Claude * improvement(workflow): port canvas styling from workflow-updates Ports the 14 styling commits your colleague added since the last sync, leaving the ~68 staging PRs on that branch alone — those are platform/core work, not this. Cherry-picked individually rather than merged so each conflict was small enough to reason about. What came in: - Core block colors unify behind a two-level map: block type -> semantic role -> accent, replacing the flat per-type table. Adds `purple` and `content` tones to ChipTag, and a shared `WorkflowTypeIcon` that replaces the hand-rolled ChipTag + accent lookup at each discovery surface. - Native triggers take semantic colors; the deployments block moves to the shared Rocket icon and drops its now-unused `iconColor`. - Running-state polish: loader artwork and position, stop hover in dark mode, the loader blended into the execution swell, and tooltips suppressed for actions that are hidden mid-run. - The toolbar drag preview clones the rendered icon container instead of rebuilding a bgColor tile, so it matches what the canvas paints. - The sidebar shows route-derived workspace identity instead of a skeleton while the full record loads. Conflict resolutions worth knowing: - The running-loader artwork went through the shared `Loader` and back to the custom SVG on their branch; the second commit is the intent, so that is what landed — keeping our `role='status'` announcement layered on top. - Two commits carried the lucide-react -> in-house icon migration along with them. That migration is a staging change we have not taken, so our imports stayed on lucide: adopting it in two files would leave the icon set split across the app. - `getMappedWorkflowTypeAccent` referenced a constant their refactor removed. It had no consumers left once the search modal moved to `WorkflowTypeIcon`, and their branch deletes it too, so it is gone here. Co-Authored-By: Claude * feat(workflow): make the subflow Start swell a real connection source Dragging an edge into whitespace opens the add-block picker, but starting that drag from a loop/parallel Start pill did nothing: the pill's border swell was visual-only. Regular blocks and the container's own exit mint a draggable cursor handle from their swell; the pill rendered only its invisible 14px static strip, so grabbing the glowing affordance started no connection at all. Everything downstream already worked and was nearly unreachable: - the drop hit-test skips subflow containers, so a release inside the loop opens the picker there (z 2000, above containers) - handleToolbarDrop parents the new block into the container at the drop point - it already carries the exact boundary rule for this source: a container start handle only wires to a child of that container The pill now runs the same cursor-handle machinery as the container view, with one deliberate difference: its temporary handle carries the branch-cursor form of the start id. The plain cursor id normalizes by block type — for a container that is `loop-end-source`/`parallel-end-source`, the exit — so a swell drag from Start would have persisted as an edge leaving the container. The branch form passes `loop-start-source`/`parallel-start-source` through normalization verbatim on both the picker and direct-connect paths; a test pins that contract. Co-Authored-By: Claude * fix(notes): stop the field's prose classes recoloring bare-surface editing Opening a note for editing shifted the text and turned it black: the ProseMirror root unconditionally carried `rich-markdown-prose rich-markdown-field-prose`, which pin the field's own ink and type ramp — `--text-primary` at 15px/25px, then 14px/22px — overriding the card's `text-current` at 14px/20px the moment the editor mounted. `surface='bare'` means the host owns typography (the Note card mirrors its rendered view via `proseClassName`), so on that surface the root now carries no shared prose classes. The field surface is untouched. Edit mode inherits the note colour's ink — including the caret — and sits on the same metrics as the read view. Co-Authored-By: Claude * fix(workflow): give the in-flight connection line contrast inside containers The drag line was drawn but camouflaged: its default stroke was the resting-edge grey (#e0e0e0), which disappears against a loop body's opaque `--surface-3` fill (~1.1:1) — so dragging an edge inside any container, nested included, showed nothing. The z-order was never the problem; the connection line layer already sits above every node. The default token is now `--text-muted`, one value with contrast on every canvas surface, still lighter than the `selected` variant so the variant hierarchy holds. No per-surface special-casing. Resting edges inside containers share the same camouflage (`--workflow-edge` on `--surface-3`) — left alone deliberately: recoloring placed edges is a design decision, not a bug fix. Co-Authored-By: Claude * fix(notes): match edit mode to the read view, and land the caret where clicked Three defects, all from the read and edit views being built independently. 1. Blocks jumped up ~12px on entering edit mode. Streamdown wraps its output in a container carrying `space-y-4` plus first/last margin resets, which outrank the per-element margins in NOTE_COMPONENTS — so that wrapper, not those margins, is what the read view actually paints. The editor had no equivalent. The rhythm is now named (NOTE_MARKDOWN_FLOW), passed to Streamdown explicitly so a dependency upgrade cannot move the read view out from under the editor, and mirrored on the ProseMirror root. Tailwind's JIT only sees literal strings so the mirror cannot be composed from the constant; a test pins the two together instead, and fails if either side drifts. 2. The caret was barely visible: it inherited the note's 75%-opacity ink. The palette owns per-colour chrome, so it now names the caret alongside the selection tint. 3. The caret always landed at the document end. The read view sits under a full-bleed overlay that must swallow the click to enter editing, so the point never reached the editor and `autofocus: 'end'` was all that was left. The view now forwards that point and the field resolves it through `posAtCoords` on create — after the DOM is laid out, which `autofocus` cannot wait for. Keyboard activation carries no point and still lands at the end. `autoFocusAt` is additive on the shared field and defaults to null, so the file editor and the other three consumers are unchanged. Co-Authored-By: Claude * fix(workflow): stop edges rendering behind top-level subflows Containers are z-indexed by nesting depth, so a top-level subflow is 0. Edges derived their z from their parent container — `+1`, or 0 with no parent — so a root-level edge landed on exactly the same z as a root-level subflow. Equal z-index falls back to DOM order, and React Flow paints the nodes layer after the edges layer, so the container's opaque body won: any edge crossing a top-level loop or parallel was drawn behind it, in-flight or persisted. Edges now sit in their own band above the whole container scale and below cards, keeping both the deeper-container-wins ordering and the rule that a line always passes behind card chrome. This is why the edge became visible only once a block was dropped: the new block is selected, and an edge inside the container was already `containerZ + 1`, clear of the tie. The in-flight connection line is declared in the same scale rather than inheriting React Flow's stylesheet default of 1001, which is both below a selected container child and outside the scale this file owns. Its stroke moves to `--text-secondary`, the token the canvas already uses for an active edge — the previous `--workflow-edge` grey is ~1.1:1 against a subflow body. Co-Authored-By: Claude * feat(notes): paste and drop images through the workspace-file pipeline Traced the file editor's image path end to end and reused it verbatim rather than minting a note-specific source: insertImages -> useUploadWorkspaceFile -> POST /api/workspaces/{id}/files/presigned -> direct-to-S3 PUT -> workspace_files row -> the editor persists the workspace-scoped /api/workspaces/{id}/files/inline URL, which the serve route authorizes by workspace membership and the embedded-image-ref machinery already recognizes for share rewriting and referenced-by-doc tracking. The shared field gains an optional `uploadImage(file) -> {url, alt} | null` capability. With it, image paste/drop uploads sequentially and inserts each at the evolving position, mirroring the file editor's flow, with a bail if the editor unmounts mid-upload; without it, the existing swallow-guard on file drops is unchanged, so the skill modal, skill fields and version-description consumers behave exactly as before. The upload mutation owns its own toasts. The note host wires the capability with folderId null, so note images land in the workspace Files root — visible, manageable and deletable there like any other upload. The note read view renders images through its Streamdown components map with the card's own sizing. Co-Authored-By: Claude * improvement for notes, subflows * fix(uploads): surface the server's message when a multipart upload is refused A file over the 50MB direct-PUT threshold goes through multipart initiate, which is where the storage quota is enforced — but the client threw away the response body and reported `Failed to initiate multipart upload: Payload Too Large`. That is the string the upload mutation puts in its toast, and it names neither which limit was hit nor by how much, so the one place that answer surfaces didn't have it. It now prefers `errorBody.error` exactly as `getPresignedUploadInfo` already does on the single-PUT path, and passes the body through as the error's details. Control flow is unchanged: still throws, still `MULTIPART_ERROR`, and the cloud-storage-absent branch above still claims its 400 first. Co-Authored-By: Claude Opus 5 (1M context) * fix(notes): restore GFM in the note read view Streamdown's `remarkPlugins` prop REPLACES its default plugin list rather than extending it, and remark-gfm is one of those defaults. The note passed `[remarkBreaks]` — so the read view silently lost every GFM construct: task lists, tables, strikethrough and autolinks. The editor writes all of them (it has TaskList, TableKit and Strike), so a note round-tripped through editing came back as raw source the moment editing closed: `- [x] HELLO` rendered as a disc bullet followed by the literal text `[x] HELLO`. `NOTE_COMPONENTS` has carried table/thead/tbody/tr/th/td entries this whole time that could never fire. Restoring the plugin is only half of it: remark-gfm marks a checklist `contains-task-list` and emits a native checkbox, which under the note's generic `ul` styling renders a checkbox sitting behind a disc bullet — the same defect the editor had before the chrome/typography split. The read view now drops the marker and indent for a task list, lays the row out as a flex line, and styles the checkbox to match `.rich-markdown-nodes input[type="checkbox"]` declaration for declaration, tick clip-path included, so the two views agree either side of a click. `remark-gfm` is now a declared dependency of the renderer package rather than one borrowed transitively from streamdown. Five tests cover the GFM surface — checked/unchecked boxes, no literal `[x]`, the marker only dropped for checklists, tables, strikethrough — and four go red with the plugin removed. Checked the other three Streamdown call sites (Chat, the chat interface renderer, the changelog): none override `remarkPlugins`, so none were affected. Co-Authored-By: Claude Opus 5 (1M context) * refactor(canvas): drop dead markers and share the tile-brightness maths Review pass over the branch against staging. Dead code removed: - `tileIconColorClass` in the renderer package — never called; only its `isLightTileColor` sibling is. - `data-connection-selector-search-frost`, `data-workflow-cursor-edge` and `data-workflow-cursor-source-side` — written on three elements, read by no stylesheet, selector or test. - `CHIP_TARGET_SELECTOR_TYPES`, `MAX_CHIPS` and `chipPriority` were exported from `canvas-rows.ts` but only used inside it. Consolidated the one real divergence: the renderer package carried a hand-copied mirror of the app's perceived-brightness maths, because it may not import app code. The copy had already drifted — it dropped the `white`/`black` keyword handling, so a block shipping `bgColor: 'white'` would render a white `currentColor` icon on a white tile on the canvas while every other surface drew it black. No block ships one today, which is exactly why nothing caught it. The function now lives in `@sim/utils/color` and both sides import it; only the 0.75 threshold stays local to each. Co-Authored-By: Claude Opus 5 (1M context) * refactor(canvas): share the z-scale, fix the preview's edge layering, and re-home strays The preview canvas carried its own z numbers and had the collision the editor canvas was fixed for: containers at nesting depth, top-level cards at an implicit 0, and edges at 0/5/10 by execution status — so a default edge tied with a top-level subflow and painted behind it, while a success edge painted over unselected cards. The scale now lives once, in `@sim/workflow-renderer/canvas-layers`, and both canvases read it. The preview keeps its status ordering, expressed inside the shared edge band rather than as a second set of magic numbers. Placement and duplication: - `perceivedBrightness` moved to `@sim/utils/color`, with its unit test, and its consumers import it directly. It had been re-exported through `lib/colors/brightness.ts`, and the renderer package kept a hand-copy. - `filterAcyclicEdges`/`wouldCreateCycle` were pass-through wrappers in the workflow store's utils over the real implementations in `@sim/workflow-types`. Deleted; the three consumers import the source. - `lib/ui/glass-surface.ts` was a one-constant, one-consumer app-wide module, and its consumer then aliased it a second time. Collapsed into the navbar shell. - `nested-subflow-node` was set on nested container nodes in both canvases with no stylesheet, selector or test behind it. `packages/workflow-renderer` now has its own vitest config, so the four mount tests for its components live with the components instead of in `apps/sim/lib/workflows/**`. That immediately earned its keep: `apps/sim` excludes test files from type-check, and once these were checked, tsc found three `SubflowNodeView` renders being handed a `renderContentEditor` prop it does not accept — a copy-paste from the note cases that had been silently ignored. Verified: type-check 23/23, 21,143 app tests + 49 renderer + 147 utils, biome clean, all 23 audits pass (`check:bare-icons` imported the moved helper and was repointed). Co-Authored-By: Claude Opus 5 (1M context) * fix field noun bug + notes * fix(notes): let a note service the canvas actions the panel editor cannot The panel editor clears any note put in front of it and renders nothing, but the block menu still routed Rename and Open Editor through it. Rename latched the editor's rename state onto the note — `handleStartRename` reads the store directly, so it saw the id the menu had just set — and nothing reset it when the clear ran. `handleSaveRename` writes to `renamingBlockIdRef`, so the header went on showing a rename field over whatever was selected next and saved that name to the note. Open Editor was a plain no-op that opened an empty pane. Rename now goes to the card, which expands and opens its own title — the same menu-to-card routing Add Image already used, so both events now live in one `lib/workflows/notes/canvas-requests.ts` and `add-image.ts` keeps only its markdown concern. Open Editor is hidden for notes. The panel editor also drops any rename whose block stops being the selected one. That is belt-and-braces for notes now, but it closes the same hole for ordinary blocks, where only the input's blur ended a rename and blur only fires if it held focus. A rename interrupted that way is now discarded rather than left pending. Co-Authored-By: Claude Opus 5 (1M context) * feat(canvas): author sentences for Snowflake and Mintlify, repoint Instagram's Staging's two new integrations shipped without a `canvasPresentation`, so their 39 operations painted the field rows the rest of the canvas has stopped using. The Instagram break is the more interesting one: staging renamed the insight metrics subblock `metrics` -> `insightMetrics` while this branch was adding sentences that named `metrics`. Both hunks merged cleanly — the union check reports the file as an exact union — and the result was two clauses pointing at a field that no longer exists, which resolves to nothing with no error and no log. Only `check:canvas-sentences` sees it. Two Snowflake sentences say something the block does not do, so they anchor elsewhere: `taskName` filters `list_task_runs`/`get_task_run` rather than keying them, and `table` filters `introspect_schema` — blank means "every one", not "not filled in yet", and a core chip would have claimed otherwise. Coverage is back to 4727/4727 operations across 321/321 blocks. Co-Authored-By: Claude Opus 5 (1M context) * fix(canvas): keep the block-type tag naming its type after a rename The header tag dropped its label whenever the block's title already said the same word, so the same block read two ways depending on nothing the user did deliberately: a freshly dropped Wait showed a bare icon, and its second copy — auto-named "Wait 2" — showed "Wait". The tag looked like a badge that appeared on rename rather than a fixed part of the header. It now always names the type, which is what loop and parallel containers already do with their own tag, so every card on the canvas reads the same way. `blockName` was only ever read for that comparison, so the prop is gone rather than left behind for a future reader to wonder about. Co-Authored-By: Claude Opus 5 (1M context) * fix(deploy): compare edge handles by port, not by spelling Two places answer "does this need redeploying?" and they load their sides differently. The client diffs the live store against `/api/workflows/[id]/deployed`; the server diffs the normalized tables against the version's raw jsonb. Only some of those paths run handles through `loadWorkflowFromNormalizedTables`, so a snapshot holding a side-anchored id (`source-right`) met a canonical one (`source`) on the other side and the set comparison read it as every edge being removed and re-added. Each answer therefore differed, and they arrive on separate query timelines: the button reads the client's, the modal badge reads the server's, so the state flipped between Live and "Update deployment" with whichever query landed last until both settled. `normalizeEdge` now canonicalizes both handles, so the comparison cannot tell two spellings of one port apart no matter how its inputs were loaded. The existing normalization in `materializeDeploymentState` stays — that path also feeds React Flow, which needs the handle it mounts to match. The preview's error port had the mirror problem: it rendered for every non-trigger block regardless of `errorEnabled`, so a card with no error row grew a red knob anyway. It now gates the way the editor canvas does, keeping the port mounted when an error edge already leaves it so React Flow cannot drop that edge. Co-Authored-By: Claude Opus 5 (1M context) * fix(deploy): stop counting the error flag twice in change detection `errorEnabled` has two homes. It persists inside the block's `data` jsonb — the realtime server `jsonb_set`s it there, and load mirrors it back onto the block as a field — so it reached the diff twice, and only some paths populate the copy. `setBlockErrorEnabled` writes the mirror alone, so right after toggling the port the live block said `errorEnabled: true` with `data.errorEnabled: false`, while the snapshot the deploy had just taken from the tables said true in both. The diff read the stale `data` and reported the workflow as changed the instant it finished deploying — then a state refetch rehydrated the block and it agreed again. That is the flip between Live and "Update deployment": the button and the modal read two different queries, so each landing swapped the answer. A block created in-session had the same shape from the other side, its `data` carrying no key at all against a persisted `false`. Excluded from `normalizeBlockData` alongside the other fields that are duplicated out of the block's own state. The block field is still compared on its own, with `!!`, so absent and `false` agree and turning the flag on is still a change. Fixing the store to write both homes was the other option and is not taken: nothing reads the in-memory `data.errorEnabled` (save and load both let the block field win), so it would add a second copy that only the diff could see — which is the shape of this bug, not its fix. Co-Authored-By: Claude Opus 5 (1M context) * refactor(blocks): give the error-output flag a column instead of two homes `errorEnabled` had no column, so it persisted inside the block's `data` jsonb and was mirrored onto the block as a field on load. Every writer had to route its `data` through `withPersistedErrorEnabled` or silently drop the toggle, the realtime op `jsonb_set`, and change detection saw the same value twice — which is what made the deploy badge flip between Live and "Update deployment" after toggling the port. Its siblings — `enabled`, `horizontal_handles`, `advanced_mode`, `trigger_mode`, `locked` — are all boolean columns; `data` is for React Flow and subflow state. The flag belongs with them, so it now has `error_enabled` and one home. The shuttle helper, its `BlockData` mirror, the store's fallback read, and the comparison exclusion the duplication forced are all gone. Backwards compatibility, since released versions draw the error port with no toggle in front of it: a block already wired to an error edge HAS the output on, because there was no other way to draw that edge. That rule is now stated in three places and none may be narrowed to read the flag alone — - the migration backfills `error_enabled` from the edges, so live rows are true before any new code reads them; - `materializeDeploymentState` derives it for a version's frozen jsonb, which the migration cannot reach — otherwise every workflow deployed before the toggle would ask to be redeployed once; - `workflow-block.tsx` keeps it at render time for states that reach the canvas through neither (imports, copilot edits), where unmounting the port would make React Flow drop the edge leaving it. The migration also moves any `data.errorEnabled` a developer created on this branch onto the column and strips the key; both statements match zero rows in production, where it never shipped. Co-Authored-By: Claude Opus 5 (1M context) * fix(canvas): realign the Snowflake and Dynatrace sentences with staging's blocks Both breaks are the class the union check cannot see: separate hunks of the same file merged cleanly, and the result names fields that no longer exist. A sentence that does resolves to nothing, with no throw and no log. Snowflake's rewrite (#6474) moved database, schema, table, warehouse and procedure onto canonical selector pairs, so seven clauses anchored on ids that are gone. Each now names both members of its pair, which is also what keeps the card readable for someone working in advanced mode. Its nine new operations have sentences. Dynatrace (#6463) scoped the mute reason to the operations that mute, because unmuting accepts exactly one — so the two unmute sentences were asking for a field their card no longer shows. They drop the clause. Coverage is 4736/4736 operations across 321/321 blocks. Co-Authored-By: Claude Opus 5 (1M context) * fix(api): drop the block-data error flag from the workflow contract Left behind by the consolidation: the flag no longer lives in `data`, and a schema that still declares it there invites the mirror back through the wire. Co-Authored-By: Claude Opus 5 (1M context) * fix(deploy): compare edge handles by port, so a falsy one cannot read as changed `loadWorkflowFromNormalizedTables` now runs handles through the canonicalizer, which falsy-coalesces — so an edge persisted with `sourceHandle: ''` loads as no handle at all. The server diffs that against the deployment version's raw jsonb, which still has `''`, and the set comparison reads one edge as removed and another added. Every workflow holding such an edge would ask to be redeployed the moment this ships, for nothing. Two write paths use `?? null` rather than `|| null`, so `''` is reachable. Canonicalized inside `normalizeEdge` rather than at either call site: the two sides are loaded by different paths and only some of them normalize, so the comparison has to be unable to tell two spellings of one port apart however its inputs arrived. This is the change reverted in 066e18ac28. That revert reasoned only about side-anchored ids, which are genuinely unreachable — it missed that the same coalesce collapses the empty string, which is not. Co-Authored-By: Claude Opus 5 (1M context) * fix(deploy): keep ignoring the error flag's old home in block data Deploying never converged: the badge asked to redeploy again the moment it finished, forever. The flag lived in `data` before it had a column. `0287` moves it, but a migration only reaches the live tables — every deployment version already written is frozen jsonb that keeps the old key. The wire schema no longer declares it either, so Zod strips it from the live state on its way to the client. So the two sides of the check genuinely differ: live `data: {}` against a snapshot's `data: {errorEnabled: false}`, reported as `data.errorEnabled` changed. Deploying cannot fix that — the next snapshot is taken from rows that still carry the key. Confirmed against a real stuck workflow: 18 versions, both blocks reporting `data.errorEnabled`, and the same two states comparing equal with this restored. Removing the exclusion in 7934df7f88 assumed the migration could reach every copy of the value. It cannot reach a frozen snapshot, so the comparison has to keep tolerating the old key regardless of where it survives. The block field is still compared on its own, with `!!`, so the flag itself is not ignored. Co-Authored-By: Claude Opus 5 (1M context) * revert(deploy): stop tolerating a block-data shape that never shipped The flag's stint inside `data` began and ended on this branch: `main` and `staging` have zero mentions of `errorEnabled`, and `ci.yml` gates every deploy job on a push to main/staging/dev, so opening a PR deploys nothing. No released version ever wrote the key, which means no production row and no production deployment snapshot can hold one. That makes the exclusion permanent code apologizing for a shape that cannot reach the database it defends. Migration 0287 already strips the key from the live tables, which is where a one-time data fix belongs; in production it matches zero rows, and on a developer database it makes the next deploy write a clean snapshot. Reverts 5ece9f9e7e. That fix was correct about the mechanism and wrong about the scope: it read a local database as evidence about production. Co-Authored-By: Claude Opus 5 (1M context) * improvement(canvas): retract bystander cards during a run, slant the sweep mark Two things about a running canvas. A run pinned every card's action bar open and suspended every card's hover on top of it, so the canvas became a wall of open swells that could neither retract nor respond to a pointer — and the `isWorkflowRunning && !isRunning` hover treatment already written for those cards was unreachable. Only the card that is actually running is pinned now; the rest behave as they do at rest, which is what makes hovering one bring its bar up again. The sweep's filled slot painted a full 24px square. It now paints a slanted band across the slot, as a hard-stop gradient rather than a `clip-path` — the two end slots already carry one for the swell silhouette and a second would have to win a specificity race with it. The stops hold `--surface-2` exactly, so only the shape changes. Each variant is spelled out because Tailwind's JIT reads literal class strings and a composed `hover-hover:${FILL}` compiles to nothing. Co-Authored-By: Claude Opus 5 (1M context) * improvement(canvas): fill the running sweep one way, and tighten its mark The sweep drained back to empty after each pass, which reads as undoing the progress the block is making. It fills left to right and starts over. The direction flag goes with it — the state is just the count now. The slanted mark also sat too far off its neighbours. Slant and tightness trade against each other here: the transparent wedge has to be at least as wide as the edge's horizontal travel, or the cut clips a corner instead of crossing the slot. Leaning 7° off vertical instead of 17° travels 2.9px across the 24px slot rather than 7.3px, which brings the wedge in from 26% to 12% — 3.2px a side against 7.8px, so the gap between marks drops from ~17.6px to ~8.4px with the slant still crossing cleanly. Co-Authored-By: Claude Opus 5 (1M context) * improvement(canvas): fill the running bar once, edge to edge The sweep restarted from empty every time it filled, so the bar kept re-running ground it had already covered. It fills left to right once and holds. The mark also sat inset in its slot, which put a gap on both sides of every join and made the row read as separate chunks instead of one bar. It now spans its slot edge to edge, leaving only the row's own `gap-[2px]` between marks, and takes its weight off vertically instead: `bg-clip-content` with symmetric padding paints a 10px band inside the 24px slot without changing the slot's size, so the swell measured around it does not move. `--surface-2` is untouched. Co-Authored-By: Claude Opus 5 (1M context) * improvement(canvas): paint the running bar as right-leaning uprights The fill read as a row of horizontal slabs. Each filled slot now paints one narrow upright bar leaning right, so a run fills as `/ / / /`. Geometry, since the two constraints fight: leaning the edge 15° off vertical carries it 4.3px across the bar's 16px height, so the transparent margin has to stay above 15% or the cut clips a corner instead of crossing top to bottom. 38%/62% leaves a 7px bar with room to spare. Height comes from `bg-clip-content` plus symmetric padding, which does not change the slot's own size, so the swell measured around it stays put. `--surface-2` is untouched. Co-Authored-By: Claude Opus 5 (1M context) * improvement(canvas): pitch the running hatch to the row, not to one bar a slot One bar per 24px button left the rest of the button empty, so the marks inherited the button grid's rhythm and sat ~19px apart — a row of isolated ticks rather than a loader. The fill repeats now, at a pitch that divides the row's own rhythm: a slot plus its `gap-[2px]` is 26px, so a 13px horizontal pitch puts exactly two bars in every slot and stays in phase across the gaps, including the 40px end slots. Bars land every 13px with a uniform 6px between them, whatever the run's length. Stops are measured along the 105° axis rather than horizontally, so they carry the `sin(105°)` factor: a 7px bar on a 13px pitch is 6.76px on a 12.56px period. Co-Authored-By: Claude Opus 5 (1M context) * fix(canvas): paint the running hatch once across the row, not per slot The bars came out bunched in some places and spread in others. Per-slot backgrounds cannot avoid that: each button starts its own gradient at its own origin, so the phase resets at every slot — and the end slots are 40px against the others' 24px, so the resets are not even uniform. Three passes of tuning the stops were all chasing a constraint the approach could not satisfy. The hatch is now one element spanning the row, so there is one gradient and one phase. It sits behind the buttons and grows by width: the run/stop button keeps an opaque fill while running and masks the part growing underneath it, and every other slot is transparent mid-sweep so the hatch reads through. The slots no longer paint anything themselves, and the per-slot filled flag goes with them. `--surface-2` is unchanged; only where it is painted moved. Co-Authored-By: Claude Opus 5 (1M context) * improvement(canvas): close the running hatch's gaps The hatch ran a 50/50 duty cycle — 6px of fill to 6px of air — which read as sparse. It now runs 8px to 3px. Both stops are measured along the 105° axis rather than horizontally, so each carries a `sin(105°)` factor; the note records that, and that closing the gap further is a matter of moving the first stop toward the second. Co-Authored-By: Claude Opus 5 (1M context) * fix(emcn): drop the brand highlight from popover menus (#6506) Context menus opted into a palette of their own — `variant='secondary'` for a brand-blue row highlight and `colorScheme='inverted'` for a dark card — so the canvas, block, toolbar, terminal, sidebar, and preview menus looked nothing like the menus everywhere else in the product. Removes both overrides so they inherit the same surface, border, and `--surface-active` highlight the terminal's overflow menu already uses, and drops the brand state from the Popover itself along with the `variant` prop that only ever selected it. One fewer way to style a menu. * feat(executor): opt-in per-block retry (#6505) * feat(executor): opt-in per-block retry Adds a per-block retry policy, off by default, surfaced in the editor's additional-fields disclosure alongside the block's other advanced settings. A block that opts in replays its handler while tries remain, then rethrows the final error so the error port behaves exactly as it does for a block that never retried — retrying only delays the existing outcome, never changes it. Retry is deliberately indiscriminate about the failure, since there is no reliable way to tell a transient error from a permanent one and classifying would silently do nothing for the generic errors people turn it on for. Only throws that are not failures are excluded: a deliberate stop, a child workflow whose own blocks already ran their policies, and the block types whose throw is control flow (human-in-the-loop, sentinels, subflow containers, notes, triggers). Eligibility lives in one predicate read by both the editor and the executor, so a block can never keep retrying after an edit that hides its control. `retry` is a nullable jsonb column; NULL means "runs once", which is how every existing block already behaves, so the change is inert until someone opts in. Bounds are clamped on read rather than rejected, so a value written before a bound moved still resolves to something runnable. Also decouples the additional-fields disclosure from `block.advancedMode`. That flag decides which member of a canonical pair serializes, so opening the disclosure used to be able to drop a block's configured credential. Expansion is now view state; the stored flag is no longer written by the editor. Retried blocks report their try count on the trace span, shown in log details. * fix(realtime): allow the write role to persist a block retry policy `update-retry` was added to the protocol but not to the write-role allowlist, so the editor applied the change optimistically while the server dropped it and the policy never reached the database. Adds a test asserting the write role holds every per-block operation the protocol declares, so the next block setting cannot repeat this silently. * fix(editor): keep a retry number field's value when it is blurred untouched Committing on blur normalized the draft unconditionally, and an untouched field's draft is null — which normalizes to the default. Focusing and leaving Max tries silently reset a configured 5 back to 3. * improvement(canvas): close the running fill to solid, slant its leading edge Gaps gone entirely: the bar is one solid fill now. The slant moves onto the growing edge, because a repeat with its gaps closed has no edges left to show. 4px of run across the 16px height is the same 15° lean the bars carried, so the fill still leans right — it just leans at its front instead of throughout. Co-Authored-By: Claude Opus 5 (1M context) * improvement(canvas): return the running fill to the squares' rhythm, sheared Marks are 24px with the row's 2px gap after them again — the geometry the slots carried before any of this — so they land where the squares did. The shear is the only thing that is new. It stays on the single spanning element rather than going back to per-slot backgrounds: one gradient means one phase, which is what lets the 24/2 rhythm hold across the 40px end slots instead of resetting at every boundary. Stops are measured along the 105° axis rather than horizontally, so both carry a `sin(105°)` factor: 24px of mark is 23.18px of stop, and the 26px pitch is 25.11px of period. Writing 24/26 directly renders ~3.5% wide and drifts out of the squares' rhythm across the row. Co-Authored-By: Claude Opus 5 (1M context) * fix(canvas): trim the running fill to the swell's tapered end The fill ran off the block. The row is a rectangle but the swell is not — its last slot cuts a diagonal so the shape narrows toward the top, and a rectangular overlay therefore painted past the gray edge up there while still sitting inside it at the bottom. The per-slot version never showed this because each button's own clip contained its fill; moving the paint onto one spanning element took that containment away with it. The overlay now carries the same taper, read off that slot's own path: 16.67px in from the row's right at the overlay's top, 3.33px at its bottom, a slope of 20/24. Only applied to the swell variant, which is the shape that tapers. Co-Authored-By: Claude Opus 5 (1M context) * fix(canvas): stop the handoff highlight pinning a bystander's toolbar open Dropping `isWorkflowRunning` from `forceOpen` was not enough: it also read `usesSelectedVisuals`, which is `isNodeSelected || isExecutionHighlighted`, and the handoff highlight covers the block feeding the running one. So the upstream card kept its bar down for the whole run — the wall of open swells this was supposed to end, one card smaller. Those are two different questions. `usesSelectedVisuals` still drives the TREATMENT — the graphite silhouette and `data-node-selected`, so the eye can follow the baton — while whether the toolbar is pinned open now keys off selection alone. The container keeps `isRunning` by itself. Selection was never a pin there, and its own tests hold it to opening on hover. Co-Authored-By: Claude Opus 5 (1M context) * fix(realtime): refresh the error flag on a block upsert `BATCH_ADD_BLOCKS` wrote `errorEnabled` on insert but left it out of the conflict clause, so re-adding an existing block id kept whatever the row already held while every sibling flag — enabled, advancedMode, triggerMode, retry, locked — was refreshed from `excluded`. The client's value was silently discarded and the old error-output state came back on the next load. Mine: the insert side gained the field when the column landed and the conflict set did not. The other two block writers delete before inserting, so no stale row survives them; this upsert was the only path that merged into one. Co-Authored-By: Claude Opus 5 (1M context) * update loader animation * change the loader --------- Co-authored-by: andresdjasso Co-authored-by: Claude Opus 5 Co-authored-by: Waleed --- .agents/skills/add-block/SKILL.md | 37 +- .claude/commands/add-block.md | 37 +- .cursor/commands/add-block.md | 37 +- apps/realtime/src/database/operations.ts | 157 +- .../src/middleware/permissions.test.ts | 13 +- apps/realtime/src/middleware/permissions.ts | 2 + apps/sim/app/playground/page.tsx | 6 +- .../rich-markdown-editor/chrome-scope.test.ts | 56 + .../image-drag-move.test.ts | 138 + .../rich-markdown-editor/image-drag-move.ts | 66 + .../rich-markdown-editor.css | 137 +- .../rich-markdown-editor.tsx | 61 +- .../rich-markdown-field.tsx | 284 +- .../components/trace-view/trace-view.tsx | 1 + .../logs/components/log-details/utils.ts | 2 +- .../components/action-bar/action-bar.tsx | 813 +- .../components/block-menu/block-menu.tsx | 40 +- .../components/canvas-menu/canvas-menu.tsx | 8 +- .../w/[workflowId]/components/chat/chat.tsx | 2 +- .../connection-block-selector.tsx | 502 + .../components/note-block/note-block.tsx | 280 +- .../note-block/note-markdown-editor.test.tsx | 192 + .../note-block/note-markdown-editor.tsx | 83 + .../note-block/use-note-image-upload.ts | 35 + .../panel/components/deploy/deploy.tsx | 21 +- .../components/editor/components/index.ts | 1 + .../retry-settings/retry-settings.test.tsx | 55 + .../retry-settings/retry-settings.tsx | 132 + .../panel/components/editor/editor.tsx | 142 +- .../toolbar/components/drag-preview.ts | 30 +- .../toolbar-item-context-menu.tsx | 8 +- .../panel/components/toolbar/toolbar.tsx | 53 +- .../w/[workflowId]/components/panel/panel.tsx | 41 +- .../components/subflows/subflow-node.tsx | 42 +- .../log-row-context-menu.tsx | 8 +- .../components/output-context-menu.tsx | 8 +- .../components/workflow-block/types.ts | 4 + .../components/workflow-block/utils.ts | 2 + .../workflow-block/workflow-block.tsx | 534 +- .../workflow-controls/workflow-controls.tsx | 9 +- .../workflow-edge/workflow-edge.tsx | 41 +- .../w/[workflowId]/hooks/index.ts | 1 + .../w/[workflowId]/hooks/use-block-visual.ts | 5 +- .../hooks/use-canvas-context-menu.ts | 1 - .../hooks/use-execution-handoff.test.ts | 71 + .../hooks/use-execution-handoff.ts | 116 + .../[workflowId]/hooks/use-node-utilities.ts | 7 +- .../hooks/use-workflow-execution.test.tsx | 71 +- .../hooks/use-workflow-execution.ts | 1 + .../utils/block-ring-utils.test.ts | 44 + .../w/[workflowId]/utils/block-ring-utils.ts | 73 +- .../[workflowId]/utils/node-position-utils.ts | 38 +- .../utils/workflow-canvas-helpers.test.ts | 16 +- .../utils/workflow-canvas-helpers.ts | 35 +- .../utils/workflow-execution-utils.ts | 4 +- .../w/[workflowId]/workflow-constants.ts | 5 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 896 +- .../preview-context-menu.tsx | 8 +- .../components/block/block.tsx | 362 +- .../components/subflow/subflow.tsx | 134 +- .../preview-workflow/preview-workflow.tsx | 49 +- .../command-items/command-items.tsx | 33 +- .../search-groups/search-groups.tsx | 5 +- .../components/search-modal/search-modal.tsx | 7 +- .../components/search-modal/utils.test.ts | 11 + .../sidebar/components/search-modal/utils.ts | 6 + .../empty-area-context-menu.tsx | 8 +- .../workspace-header.test.tsx | 18 +- .../workspace-header/workspace-header.tsx | 70 +- .../w/components/sidebar/sidebar.tsx | 4 +- apps/sim/blocks/AGENTS.md | 290 + apps/sim/blocks/blocks/a2a.ts | 24 + apps/sim/blocks/blocks/agent.ts | 10 + apps/sim/blocks/blocks/agentmail.ts | 92 + apps/sim/blocks/blocks/agentphone.ts | 86 + apps/sim/blocks/blocks/agiloft.ts | 79 + apps/sim/blocks/blocks/ahrefs.ts | 129 + apps/sim/blocks/blocks/airtable.ts | 50 + apps/sim/blocks/blocks/airweave.ts | 9 + apps/sim/blocks/blocks/algolia.ts | 58 + apps/sim/blocks/blocks/amplitude.ts | 61 + apps/sim/blocks/blocks/api.ts | 10 + apps/sim/blocks/blocks/apify.ts | 33 + apps/sim/blocks/blocks/apollo.ts | 105 + apps/sim/blocks/blocks/appconfig.ts | 118 + apps/sim/blocks/blocks/arxiv.ts | 17 + apps/sim/blocks/blocks/asana.ts | 60 + apps/sim/blocks/blocks/ashby.ts | 86 + apps/sim/blocks/blocks/athena.ts | 52 + apps/sim/blocks/blocks/attio.ts | 173 + apps/sim/blocks/blocks/azure_devops.ts | 68 + apps/sim/blocks/blocks/box.ts | 60 + apps/sim/blocks/blocks/brandfetch.ts | 9 + apps/sim/blocks/blocks/brex.ts | 137 + apps/sim/blocks/blocks/brightdata.ts | 39 + apps/sim/blocks/blocks/browser_use.ts | 9 + apps/sim/blocks/blocks/buffer.ts | 34 + apps/sim/blocks/blocks/calcom.ts | 73 + apps/sim/blocks/blocks/calendly.ts | 39 + apps/sim/blocks/blocks/clay.ts | 7 + apps/sim/blocks/blocks/clerk.ts | 135 + apps/sim/blocks/blocks/clickhouse.ts | 58 + apps/sim/blocks/blocks/clickup.ts | 158 + apps/sim/blocks/blocks/cloudflare.ts | 56 + apps/sim/blocks/blocks/cloudformation.ts | 56 + apps/sim/blocks/blocks/cloudwatch.ts | 74 + apps/sim/blocks/blocks/codepipeline.ts | 59 + apps/sim/blocks/blocks/confluence.ts | 231 + apps/sim/blocks/blocks/context_dev.ts | 73 + apps/sim/blocks/blocks/convex.ts | 39 + apps/sim/blocks/blocks/credential.ts | 9 + apps/sim/blocks/blocks/crowdstrike.ts | 19 + apps/sim/blocks/blocks/cursor.ts | 36 + apps/sim/blocks/blocks/dagster.ts | 85 + apps/sim/blocks/blocks/databricks.ts | 45 + apps/sim/blocks/blocks/datadog.ts | 42 + apps/sim/blocks/blocks/datagma.ts | 23 + apps/sim/blocks/blocks/daytona.ts | 53 + apps/sim/blocks/blocks/deployments.ts | 32 +- apps/sim/blocks/blocks/devin.ts | 37 + apps/sim/blocks/blocks/discord.ts | 150 + apps/sim/blocks/blocks/docusign.ts | 35 + apps/sim/blocks/blocks/downdetector.ts | 77 + apps/sim/blocks/blocks/dropbox.ts | 53 + apps/sim/blocks/blocks/dropcontact.ts | 21 + apps/sim/blocks/blocks/dspy.ts | 20 + apps/sim/blocks/blocks/dub.ts | 88 + apps/sim/blocks/blocks/duckduckgo.ts | 6 + apps/sim/blocks/blocks/dynamodb.ts | 43 + apps/sim/blocks/blocks/dynatrace.ts | 160 + apps/sim/blocks/blocks/elasticsearch.ts | 57 + apps/sim/blocks/blocks/elevenlabs.ts | 37 + apps/sim/blocks/blocks/emailbison.ts | 43 + apps/sim/blocks/blocks/embeddings.ts | 12 + apps/sim/blocks/blocks/enrich.ts | 116 + apps/sim/blocks/blocks/enrichment.ts | 37 + apps/sim/blocks/blocks/enrow.ts | 12 + apps/sim/blocks/blocks/evaluator.ts | 9 + apps/sim/blocks/blocks/evernote.ts | 38 + apps/sim/blocks/blocks/exa.ts | 22 + apps/sim/blocks/blocks/extend.ts | 21 + apps/sim/blocks/blocks/fathom.ts | 35 + apps/sim/blocks/blocks/file.ts | 98 + apps/sim/blocks/blocks/findymail.ts | 56 + apps/sim/blocks/blocks/firecrawl.ts | 41 + apps/sim/blocks/blocks/fireflies.ts | 57 + apps/sim/blocks/blocks/flint.ts | 16 + apps/sim/blocks/blocks/function.ts | 4 + apps/sim/blocks/blocks/gamma.ts | 21 + apps/sim/blocks/blocks/generic_webhook.ts | 13 + apps/sim/blocks/blocks/github.ts | 312 + apps/sim/blocks/blocks/gitlab.ts | 253 + apps/sim/blocks/blocks/gmail.ts | 82 + apps/sim/blocks/blocks/gong.ts | 136 + apps/sim/blocks/blocks/google.ts | 10 + apps/sim/blocks/blocks/google_ads.ts | 32 + apps/sim/blocks/blocks/google_appsheet.ts | 23 + apps/sim/blocks/blocks/google_bigquery.ts | 51 + apps/sim/blocks/blocks/google_books.ts | 13 + apps/sim/blocks/blocks/google_calendar.ts | 102 + apps/sim/blocks/blocks/google_contacts.ts | 24 + apps/sim/blocks/blocks/google_docs.ts | 84 + apps/sim/blocks/blocks/google_drive.ts | 177 + apps/sim/blocks/blocks/google_forms.ts | 49 + apps/sim/blocks/blocks/google_groups.ts | 59 + apps/sim/blocks/blocks/google_maps.ts | 60 + apps/sim/blocks/blocks/google_meet.ts | 27 + apps/sim/blocks/blocks/google_pagespeed.ts | 10 + apps/sim/blocks/blocks/google_sheets.ts | 93 + apps/sim/blocks/blocks/google_slides.ts | 238 + apps/sim/blocks/blocks/google_tasks.ts | 38 + apps/sim/blocks/blocks/google_translate.ts | 13 + apps/sim/blocks/blocks/google_vault.ts | 128 + apps/sim/blocks/blocks/grafana.ts | 84 + apps/sim/blocks/blocks/grain.ts | 70 + apps/sim/blocks/blocks/granola.ts | 18 + apps/sim/blocks/blocks/greenhouse.ts | 41 + apps/sim/blocks/blocks/greptile.ts | 19 + apps/sim/blocks/blocks/guardrails.ts | 14 + apps/sim/blocks/blocks/hex.ts | 73 + apps/sim/blocks/blocks/hubspot.ts | 239 + apps/sim/blocks/blocks/huggingface.ts | 10 + apps/sim/blocks/blocks/human_in_the_loop.ts | 9 +- apps/sim/blocks/blocks/hunter.ts | 34 + apps/sim/blocks/blocks/iam.ts | 84 + apps/sim/blocks/blocks/icypeas.ts | 18 + apps/sim/blocks/blocks/identity_center.ts | 32 + apps/sim/blocks/blocks/image_generator.ts | 18 + apps/sim/blocks/blocks/imap.ts | 8 + apps/sim/blocks/blocks/incidentio.ts | 128 + apps/sim/blocks/blocks/infisical.ts | 30 + apps/sim/blocks/blocks/instagram.ts | 99 + apps/sim/blocks/blocks/instantly.ts | 59 + apps/sim/blocks/blocks/intercom.ts | 110 + apps/sim/blocks/blocks/jina.ts | 16 + apps/sim/blocks/blocks/jira.ts | 115 + .../blocks/blocks/jira_service_management.ts | 178 + apps/sim/blocks/blocks/jupyter.ts | 48 + apps/sim/blocks/blocks/kalshi.ts | 91 + apps/sim/blocks/blocks/ketch.ts | 32 + apps/sim/blocks/blocks/knowledge.ts | 70 + apps/sim/blocks/blocks/langsmith.ts | 27 + apps/sim/blocks/blocks/latex.ts | 22 + apps/sim/blocks/blocks/launchdarkly.ts | 54 + apps/sim/blocks/blocks/leadmagic.ts | 51 + apps/sim/blocks/blocks/lemlist.ts | 32 + apps/sim/blocks/blocks/linear.ts | 318 + apps/sim/blocks/blocks/linkedin.ts | 12 + apps/sim/blocks/blocks/linkup.ts | 10 + apps/sim/blocks/blocks/linq.ts | 172 + apps/sim/blocks/blocks/logfire.ts | 24 + apps/sim/blocks/blocks/logs.ts | 38 + apps/sim/blocks/blocks/loops.ts | 56 + apps/sim/blocks/blocks/luma.ts | 48 + apps/sim/blocks/blocks/mailchimp.ts | 286 + apps/sim/blocks/blocks/mailgun.ts | 28 + apps/sim/blocks/blocks/managed_agent.ts | Bin 20631 -> 22759 bytes apps/sim/blocks/blocks/mcp.ts | 7 + apps/sim/blocks/blocks/mem0.ts | 21 + apps/sim/blocks/blocks/memory.ts | 14 + apps/sim/blocks/blocks/microsoft_ad.ts | 46 + apps/sim/blocks/blocks/microsoft_dataverse.ts | 86 + apps/sim/blocks/blocks/microsoft_excel.ts | 94 + apps/sim/blocks/blocks/microsoft_planner.ts | 78 + apps/sim/blocks/blocks/microsoft_teams.ts | 91 + apps/sim/blocks/blocks/millionverifier.ts | 11 + apps/sim/blocks/blocks/mintlify.ts | 49 + apps/sim/blocks/blocks/mistral_parse.ts | 31 + apps/sim/blocks/blocks/monday.ts | 89 + apps/sim/blocks/blocks/mongodb.ts | 30 + apps/sim/blocks/blocks/mothership.ts | 10 + apps/sim/blocks/blocks/mysql.ts | 30 + apps/sim/blocks/blocks/neo4j.ts | 14 + apps/sim/blocks/blocks/neverbounce.ts | 11 + apps/sim/blocks/blocks/new_relic.ts | 19 + apps/sim/blocks/blocks/note.test.ts | 54 + apps/sim/blocks/blocks/note.ts | 14 + apps/sim/blocks/blocks/notion.ts | 100 + apps/sim/blocks/blocks/obsidian.ts | 58 + apps/sim/blocks/blocks/okta.ts | 54 + apps/sim/blocks/blocks/onedrive.ts | 79 + apps/sim/blocks/blocks/onepassword.ts | 41 + apps/sim/blocks/blocks/openai.ts | 9 + apps/sim/blocks/blocks/outlook.ts | 115 + apps/sim/blocks/blocks/pagerduty.ts | 57 + apps/sim/blocks/blocks/parallel.ts | 19 + apps/sim/blocks/blocks/peopledatalabs.ts | 59 + apps/sim/blocks/blocks/perplexity.ts | 16 + apps/sim/blocks/blocks/persona.ts | 83 + apps/sim/blocks/blocks/pi.ts | 10 + apps/sim/blocks/blocks/pinecone.ts | 49 + apps/sim/blocks/blocks/pipedrive.ts | 81 + apps/sim/blocks/blocks/polymarket.ts | 78 + apps/sim/blocks/blocks/postgresql.ts | 33 + apps/sim/blocks/blocks/posthog.ts | 191 + apps/sim/blocks/blocks/profound.ts | 80 + apps/sim/blocks/blocks/prospeo.ts | 51 + apps/sim/blocks/blocks/pulse.ts | 24 + apps/sim/blocks/blocks/qdrant.ts | 17 + apps/sim/blocks/blocks/quartr.ts | 68 + apps/sim/blocks/blocks/quiver.ts | 20 + apps/sim/blocks/blocks/railway.ts | 105 + apps/sim/blocks/blocks/rb2b.ts | 54 + apps/sim/blocks/blocks/rds.ts | 33 + apps/sim/blocks/blocks/reddit.ts | 125 + apps/sim/blocks/blocks/redis.ts | 63 + apps/sim/blocks/blocks/reducto.ts | 24 + apps/sim/blocks/blocks/resend.ts | 35 + apps/sim/blocks/blocks/response.ts | 9 + apps/sim/blocks/blocks/revenuecat.ts | 50 + apps/sim/blocks/blocks/rippling.ts | 224 + apps/sim/blocks/blocks/rocketlane.ts | 245 + apps/sim/blocks/blocks/rootly.ts | 181 + apps/sim/blocks/blocks/router.ts | 11 + apps/sim/blocks/blocks/rss.ts | 9 + apps/sim/blocks/blocks/s3.ts | 54 + apps/sim/blocks/blocks/salesforce.ts | 146 + apps/sim/blocks/blocks/sap_concur.ts | 255 + apps/sim/blocks/blocks/sap_s4hana.ts | 134 +- apps/sim/blocks/blocks/schedule.ts | 24 + apps/sim/blocks/blocks/search.ts | 6 + apps/sim/blocks/blocks/secrets_manager.ts | 38 + apps/sim/blocks/blocks/sendblue.ts | 29 + apps/sim/blocks/blocks/sendgrid.ts | 41 + apps/sim/blocks/blocks/sentry.ts | 51 + apps/sim/blocks/blocks/serper.ts | 10 + apps/sim/blocks/blocks/servicenow.ts | 57 + apps/sim/blocks/blocks/ses.ts | 90 + apps/sim/blocks/blocks/sftp.ts | 20 + apps/sim/blocks/blocks/sharepoint.ts | 162 + apps/sim/blocks/blocks/shopify.ts | 93 + apps/sim/blocks/blocks/sim_workspace_event.ts | 17 + apps/sim/blocks/blocks/similarweb.ts | 30 + apps/sim/blocks/blocks/sixtyfour.ts | 28 + apps/sim/blocks/blocks/slack.ts | 317 +- apps/sim/blocks/blocks/smartlead.ts | 119 + apps/sim/blocks/blocks/smtp.ts | 10 + apps/sim/blocks/blocks/snowflake.ts | 119 + apps/sim/blocks/blocks/sportmonks.ts | 507 + apps/sim/blocks/blocks/spotify.ts | 271 + apps/sim/blocks/blocks/sqs.ts | 12 + apps/sim/blocks/blocks/square.ts | 136 + apps/sim/blocks/blocks/ssh.ts | 59 + apps/sim/blocks/blocks/stagehand.ts | 15 + apps/sim/blocks/blocks/stripe.ts | 172 + apps/sim/blocks/blocks/sts.ts | 27 + apps/sim/blocks/blocks/stt.ts | 22 + apps/sim/blocks/blocks/supabase.ts | 118 + apps/sim/blocks/blocks/table.ts | 77 + apps/sim/blocks/blocks/table_v2.ts | 70 + apps/sim/blocks/blocks/tailscale.ts | 71 + apps/sim/blocks/blocks/tavily.ts | 25 + apps/sim/blocks/blocks/telegram.ts | 112 + apps/sim/blocks/blocks/temporal.ts | 97 + apps/sim/blocks/blocks/textract.ts | 44 + apps/sim/blocks/blocks/thinking.ts | 4 + apps/sim/blocks/blocks/thrive.ts | 160 + apps/sim/blocks/blocks/tiktok.ts | 27 + apps/sim/blocks/blocks/tinybird.ts | 27 + apps/sim/blocks/blocks/translate.ts | 9 + apps/sim/blocks/blocks/trello.ts | 103 + apps/sim/blocks/blocks/trigger_dev.ts | 138 + apps/sim/blocks/blocks/tts.ts | 12 + apps/sim/blocks/blocks/twilio.ts | 21 + apps/sim/blocks/blocks/twilio_voice.ts | 26 + apps/sim/blocks/blocks/typeform.ts | 38 + apps/sim/blocks/blocks/upstash.ts | 51 + apps/sim/blocks/blocks/uptimerobot.ts | 77 + apps/sim/blocks/blocks/vanta.ts | 102 + apps/sim/blocks/blocks/variables.ts | 6 + apps/sim/blocks/blocks/vercel.ts | 206 + apps/sim/blocks/blocks/video_generator.ts | 9 + apps/sim/blocks/blocks/vision.ts | 22 + apps/sim/blocks/blocks/wait.ts | 11 + apps/sim/blocks/blocks/wealthbox.ts | 24 + apps/sim/blocks/blocks/webflow.ts | 43 + apps/sim/blocks/blocks/webhook_request.ts | 9 + apps/sim/blocks/blocks/whatsapp.ts | 54 + apps/sim/blocks/blocks/wikipedia.ts | 14 + apps/sim/blocks/blocks/wiza.ts | 30 + apps/sim/blocks/blocks/wordpress.ts | 120 + apps/sim/blocks/blocks/workday.ts | 41 + apps/sim/blocks/blocks/workflow.ts | 9 + apps/sim/blocks/blocks/workflow_input.ts | 9 + apps/sim/blocks/blocks/x.ts | 106 + apps/sim/blocks/blocks/youtube.ts | 36 + apps/sim/blocks/blocks/zendesk.ts | 83 + apps/sim/blocks/blocks/zep.ts | 30 + apps/sim/blocks/blocks/zerobounce.ts | 11 + apps/sim/blocks/blocks/zoho-desk.ts | 43 + apps/sim/blocks/blocks/zoom.ts | 49 + apps/sim/blocks/blocks/zoominfo.ts | 34 + apps/sim/blocks/types.ts | 92 + apps/sim/components/icons.tsx | 2 +- .../components/ui/thinking-loader.module.css | 273 +- .../components/ui/thinking-loader.test.tsx | 143 + apps/sim/components/ui/thinking-loader.tsx | 162 +- .../execution/block-executor.retry.test.ts | 210 + apps/sim/executor/execution/block-executor.ts | 61 +- .../executor/execution/block-retry.test.ts | 178 + apps/sim/executor/execution/block-retry.ts | 41 + .../executor/execution/edge-manager.test.ts | 23 + apps/sim/executor/execution/edge-manager.ts | 7 +- apps/sim/executor/types.ts | 2 + apps/sim/hooks/use-collaborative-workflow.ts | 46 +- apps/sim/hooks/use-selector-display-name.ts | 35 +- apps/sim/lib/api/contracts/workflows.ts | 22 + apps/sim/lib/colors/brightness.test.ts | 34 +- apps/sim/lib/colors/brightness.ts | 37 +- apps/sim/lib/colors/index.ts | 2 +- .../execution/trace-spans/span-factory.ts | 1 + apps/sim/lib/logs/types.ts | 2 + apps/sim/lib/uploads/client/direct-upload.ts | 9 +- .../workflows/autolayout/change-set.test.ts | 18 + .../sim/lib/workflows/autolayout/core.test.ts | 98 +- apps/sim/lib/workflows/autolayout/core.ts | 48 +- apps/sim/lib/workflows/autolayout/types.ts | 16 + .../lib/workflows/autolayout/utils.test.ts | 152 + apps/sim/lib/workflows/autolayout/utils.ts | 211 +- .../workflows/blocks/canvas-card-fields.ts | 118 + .../blocks/canvas-presentation.test.ts | 105 + .../workflows/blocks/canvas-presentation.ts | 115 + apps/sim/lib/workflows/blocks/canvas-rows.ts | 93 + .../blocks/canvas-sentence-imperative.test.ts | 73 + .../blocks/canvas-sentence-imperative.ts | 75 + .../blocks/canvas-sentence-noun.test.ts | 105 + .../workflows/blocks/canvas-sentence-noun.ts | 190 + .../blocks/canvas-sentence-render.ts | 98 + .../blocks/canvas-sentence-validation.test.ts | 795 + .../blocks/canvas-sentence-validation.ts | 805 + .../blocks/canvas-sentence-view.test.tsx | 114 + .../workflows/blocks/canvas-sentence.test.ts | 410 + .../lib/workflows/blocks/canvas-sentence.ts | 248 + .../blocks/canvas-trigger-sentence.ts | 84 + .../blocks/deterministic-dimensions.test.ts | 119 + .../blocks/deterministic-dimensions.ts | 71 +- .../lib/workflows/blocks/retry-eligibility.ts | 32 + .../lib/workflows/comparison/compare.test.ts | 58 + apps/sim/lib/workflows/comparison/compare.ts | 16 +- .../sim/lib/workflows/comparison/normalize.ts | 25 +- apps/sim/lib/workflows/defaults.ts | 1 + apps/sim/lib/workflows/diff/diff-engine.ts | 13 +- .../sim/lib/workflows/notes/add-image.test.ts | 34 + apps/sim/lib/workflows/notes/add-image.ts | 14 + .../lib/workflows/notes/canvas-requests.ts | 35 + .../lib/workflows/persistence/duplicate.ts | 9 +- .../lib/workflows/persistence/utils.test.ts | 28 + apps/sim/lib/workflows/persistence/utils.ts | 34 +- .../workflows/sanitization/json-sanitizer.ts | 15 +- .../lib/workflows/subblocks/display.test.ts | 31 + apps/sim/lib/workflows/subblocks/display.ts | 41 +- .../scripts/canvas-sentence-audit-corpus.ts | 149 + apps/sim/scripts/canvas-sentence-spec.ts | 203 + apps/sim/scripts/check-canvas-sentences.ts | 257 + apps/sim/serializer/index.ts | 5 + apps/sim/serializer/types.ts | 3 + apps/sim/stores/execution/store.ts | 3 +- apps/sim/stores/modals/search/types.ts | 4 +- apps/sim/stores/workflows/utils.test.ts | 74 +- apps/sim/stores/workflows/utils.ts | 16 +- .../stores/workflows/workflow/store.test.ts | 18 + apps/sim/stores/workflows/workflow/store.ts | 46 +- apps/sim/stores/workflows/workflow/types.ts | 4 + apps/sim/stores/workflows/workflow/utils.ts | 21 - apps/sim/tailwind.config.ts | 16 + apps/sim/triggers/ashby/utils.ts | 1 + apps/sim/triggers/attio/record_created.ts | 1 + apps/sim/triggers/calendly/invitee_created.ts | 1 + apps/sim/triggers/circleback/webhook.ts | 1 + apps/sim/triggers/clickup/task_created.ts | 1 + apps/sim/triggers/fathom/new_meeting.ts | 1 + apps/sim/triggers/fathom/webhook.ts | 1 + apps/sim/triggers/github/issue_opened.ts | 1 + apps/sim/triggers/gitlab/utils.ts | 1 + apps/sim/triggers/gmail/poller.ts | 1 + apps/sim/triggers/hubspot/poller.ts | 1 + apps/sim/triggers/imap/poller.ts | 1 + apps/sim/triggers/index.ts | 3 + apps/sim/triggers/linear/issue_created.ts | 1 + apps/sim/triggers/linear/utils.ts | 1 + .../triggers/microsoftteams/chat_webhook.ts | 2 + apps/sim/triggers/microsoftteams/webhook.ts | 1 + apps/sim/triggers/monday/utils.ts | 2 + apps/sim/triggers/outlook/poller.ts | 1 + apps/sim/triggers/rss/poller.ts | 1 + apps/sim/triggers/stripe/webhook.ts | 1 + .../triggers/tiktok/post_publish_complete.ts | 1 + apps/sim/triggers/typeform/webhook.ts | 1 + .../webflow/collection_item_created.ts | 1 + bun.lock | 7 + package.json | 1 + .../0287_workflow_blocks_error_enabled.sql | 22 + .../migrations/0288_workflow_blocks_retry.sql | 2 + .../db/migrations/meta/0287_snapshot.json | 18797 +++++++++++++++ .../db/migrations/meta/0288_snapshot.json | 18803 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 14 + packages/db/schema.ts | 3 + .../emcn/src/components/chip-tag/chip-tag.tsx | 96 +- packages/emcn/src/components/chip/chip.tsx | 34 +- packages/emcn/src/components/index.ts | 1 + .../emcn/src/components/popover/popover.tsx | 45 +- .../emcn/src/components/tooltip/tooltip.tsx | 41 +- .../src/hooks/use-prefers-reduced-motion.ts | 40 + packages/emcn/src/index.ts | 1 + packages/realtime-protocol/src/constants.ts | 2 + packages/realtime-protocol/src/schemas.ts | 15 + .../src/factories/permission.factory.ts | 1 + packages/utils/package.json | 4 + packages/utils/src/color.test.ts | 29 + packages/utils/src/color.ts | 41 + packages/workflow-persistence/src/load.ts | 12 +- packages/workflow-persistence/src/save.ts | 17 +- packages/workflow-renderer/package.json | 16 +- .../workflow-renderer/src/canvas-layers.ts | 50 + packages/workflow-renderer/src/dimensions.ts | 94 +- .../edge/workflow-edge-view-mount.test.tsx | 213 + .../src/edge/workflow-edge-view.tsx | 202 +- packages/workflow-renderer/src/index.ts | 61 +- .../src/lib/humanize-block-name.ts | 29 + .../src/lib/tile-icon-color.ts | 36 +- .../src/note/note-block-view.tsx | 880 +- .../src/note/note-card-actions.test.tsx | 245 + .../workflow-renderer/src/note/note-colors.ts | 79 + .../src/note/note-content.ts | 21 + .../src/note/note-markdown-render.test.tsx | 139 + .../src/subflow/subflow-node-view.tsx | 610 +- .../workflow-block/canvas-sentence-view.tsx | 71 + .../src/workflow-block/inline-chip.tsx | 31 + .../src/workflow-block/source-handle.ts | 55 + .../src/workflow-block/sub-block-row-view.tsx | 82 +- .../workflow-block/use-action-menu-swell.ts | 207 + .../workflow-block-border-mount.test.tsx | 1101 + .../workflow-block/workflow-block-border.tsx | 1757 ++ .../workflow-block-view-interaction.test.tsx | 349 + .../workflow-block/workflow-block-view.tsx | 1006 +- packages/workflow-renderer/tsconfig.json | 5 +- packages/workflow-renderer/vitest.config.ts | 12 + packages/workflow-renderer/vitest.setup.ts | 8 + packages/workflow-types/src/workflow.ts | 217 +- scripts/check-bare-icons.ts | 2 +- 500 files changed, 75145 insertions(+), 1968 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-markdown-editor.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-markdown-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/use-note-image-upload.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-execution-handoff.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-execution-handoff.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.test.ts create mode 100644 apps/sim/blocks/blocks/note.test.ts create mode 100644 apps/sim/components/ui/thinking-loader.test.tsx create mode 100644 apps/sim/executor/execution/block-executor.retry.test.ts create mode 100644 apps/sim/executor/execution/block-retry.test.ts create mode 100644 apps/sim/executor/execution/block-retry.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-card-fields.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-presentation.test.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-presentation.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-rows.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-imperative.test.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-imperative.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-noun.test.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-noun.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-render.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-validation.test.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-validation.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence-view.test.tsx create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence.test.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-sentence.ts create mode 100644 apps/sim/lib/workflows/blocks/canvas-trigger-sentence.ts create mode 100644 apps/sim/lib/workflows/blocks/deterministic-dimensions.test.ts create mode 100644 apps/sim/lib/workflows/blocks/retry-eligibility.ts create mode 100644 apps/sim/lib/workflows/notes/add-image.test.ts create mode 100644 apps/sim/lib/workflows/notes/add-image.ts create mode 100644 apps/sim/lib/workflows/notes/canvas-requests.ts create mode 100644 apps/sim/scripts/canvas-sentence-audit-corpus.ts create mode 100644 apps/sim/scripts/canvas-sentence-spec.ts create mode 100644 apps/sim/scripts/check-canvas-sentences.ts create mode 100644 packages/db/migrations/0287_workflow_blocks_error_enabled.sql create mode 100644 packages/db/migrations/0288_workflow_blocks_retry.sql create mode 100644 packages/db/migrations/meta/0287_snapshot.json create mode 100644 packages/db/migrations/meta/0288_snapshot.json create mode 100644 packages/emcn/src/hooks/use-prefers-reduced-motion.ts create mode 100644 packages/utils/src/color.test.ts create mode 100644 packages/utils/src/color.ts create mode 100644 packages/workflow-renderer/src/canvas-layers.ts create mode 100644 packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx create mode 100644 packages/workflow-renderer/src/lib/humanize-block-name.ts create mode 100644 packages/workflow-renderer/src/note/note-card-actions.test.tsx create mode 100644 packages/workflow-renderer/src/note/note-colors.ts create mode 100644 packages/workflow-renderer/src/note/note-content.ts create mode 100644 packages/workflow-renderer/src/note/note-markdown-render.test.tsx create mode 100644 packages/workflow-renderer/src/workflow-block/canvas-sentence-view.tsx create mode 100644 packages/workflow-renderer/src/workflow-block/inline-chip.tsx create mode 100644 packages/workflow-renderer/src/workflow-block/source-handle.ts create mode 100644 packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts create mode 100644 packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx create mode 100644 packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx create mode 100644 packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx create mode 100644 packages/workflow-renderer/vitest.config.ts create mode 100644 packages/workflow-renderer/vitest.setup.ts diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index c74e459a06..378c9b6879 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -52,6 +52,12 @@ export const {ServiceName}Block: BlockConfig = { // Auth mode authMode: AuthMode.OAuth, // or AuthMode.ApiKey + // Card summary sentences — see "Canvas Sentences" below + canvasPresentation: { + defaultTitle: '{Default Operation}', + sentences: { byOperation: { /* one per operation dropdown option id */ } }, + }, + subBlocks: [ // Define all UI fields here ], @@ -945,6 +951,35 @@ Derive templates from the service's real use cases. Each prompt should name a co - **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. - **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. +## Canvas Sentences + +Every block declares a one-line prose summary that replaces its card's field rows: + +``` +Slack ← header (already names the block) +Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips +``` + +Write one `byOperation` entry per operation dropdown option (or a single `default` +when the block has no operation dropdown). + +**The full authoring contract — voice, structure, and the two mistakes that break +cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it +before writing any.** The two failures worth repeating here, because both are +invisible at runtime: + +1. A clause naming only one member of a `canonicalParamId` pair drops the sentence + for every advanced-mode user. List all members: + `field: ['channelSelector', 'manualChannel']`. +2. A clause referencing a subblock whose `condition` excludes that operation can + never render. + +Validate before finishing: + +```bash +bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} +``` + ## Generated artifacts Adding a block on its own needs no **tool metadata** regeneration — a block references existing @@ -963,7 +998,6 @@ bun run integration-catalog:check The catalog check independently derives deployment metadata from the executable block registry and compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated diff and keep only intentional changes. - ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -991,6 +1025,7 @@ diff and keep only intentional changes. - [ ] Exported `{Service}BlockMeta` with at least 7 templates - [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) - [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) +- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage ## Final Validation (Required) diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 57df2b4dd1..1f9a8554af 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -51,6 +51,12 @@ export const {ServiceName}Block: BlockConfig = { // Auth mode authMode: AuthMode.OAuth, // or AuthMode.ApiKey + // Card summary sentences — see "Canvas Sentences" below + canvasPresentation: { + defaultTitle: '{Default Operation}', + sentences: { byOperation: { /* one per operation dropdown option id */ } }, + }, + subBlocks: [ // Define all UI fields here ], @@ -944,6 +950,35 @@ Derive templates from the service's real use cases. Each prompt should name a co - **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. - **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. +## Canvas Sentences + +Every block declares a one-line prose summary that replaces its card's field rows: + +``` +Slack ← header (already names the block) +Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips +``` + +Write one `byOperation` entry per operation dropdown option (or a single `default` +when the block has no operation dropdown). + +**The full authoring contract — voice, structure, and the two mistakes that break +cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it +before writing any.** The two failures worth repeating here, because both are +invisible at runtime: + +1. A clause naming only one member of a `canonicalParamId` pair drops the sentence + for every advanced-mode user. List all members: + `field: ['channelSelector', 'manualChannel']`. +2. A clause referencing a subblock whose `condition` excludes that operation can + never render. + +Validate before finishing: + +```bash +bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} +``` + ## Generated artifacts Adding a block on its own needs no **tool metadata** regeneration — a block references existing @@ -962,7 +997,6 @@ bun run integration-catalog:check The catalog check independently derives deployment metadata from the executable block registry and compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated diff and keep only intentional changes. - ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -990,6 +1024,7 @@ diff and keep only intentional changes. - [ ] Exported `{Service}BlockMeta` with at least 7 templates - [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) - [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) +- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage ## Final Validation (Required) diff --git a/.cursor/commands/add-block.md b/.cursor/commands/add-block.md index 1ac378de89..d73e9efbac 100644 --- a/.cursor/commands/add-block.md +++ b/.cursor/commands/add-block.md @@ -46,6 +46,12 @@ export const {ServiceName}Block: BlockConfig = { // Auth mode authMode: AuthMode.OAuth, // or AuthMode.ApiKey + // Card summary sentences — see "Canvas Sentences" below + canvasPresentation: { + defaultTitle: '{Default Operation}', + sentences: { byOperation: { /* one per operation dropdown option id */ } }, + }, + subBlocks: [ // Define all UI fields here ], @@ -939,6 +945,35 @@ Derive templates from the service's real use cases. Each prompt should name a co - **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. - **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. +## Canvas Sentences + +Every block declares a one-line prose summary that replaces its card's field rows: + +``` +Slack ← header (already names the block) +Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips +``` + +Write one `byOperation` entry per operation dropdown option (or a single `default` +when the block has no operation dropdown). + +**The full authoring contract — voice, structure, and the two mistakes that break +cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it +before writing any.** The two failures worth repeating here, because both are +invisible at runtime: + +1. A clause naming only one member of a `canonicalParamId` pair drops the sentence + for every advanced-mode user. List all members: + `field: ['channelSelector', 'manualChannel']`. +2. A clause referencing a subblock whose `condition` excludes that operation can + never render. + +Validate before finishing: + +```bash +bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} +``` + ## Generated artifacts Adding a block on its own needs no **tool metadata** regeneration — a block references existing @@ -957,7 +992,6 @@ bun run integration-catalog:check The catalog check independently derives deployment metadata from the executable block registry and compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated diff and keep only intentional changes. - ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -985,6 +1019,7 @@ diff and keep only intentional changes. - [ ] Exported `{Service}BlockMeta` with at least 7 templates - [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) - [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) +- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage ## Final Validation (Required) diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index cec1c8c0ba..1cf601ae96 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -32,6 +32,8 @@ import { isKnownWorkflowTriggerBlock, isWorkflowAnnotationOnlyBlockType, isWorkflowBlockProtected, + normalizeWorkflowEdgeSourceHandle, + normalizeWorkflowEdgeTargetHandle, } from '@sim/workflow-types/workflow' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' @@ -57,13 +59,21 @@ function toEdgeHandles(edge: PersistedEdgeRecord) { } interface EdgeAddCandidate { - id?: string + id: string source: string target: string sourceHandle?: string | null targetHandle?: string | null } +function canonicalizeEdgeAddCandidate(edge: EdgeAddCandidate): EdgeAddCandidate { + return { + ...edge, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), + } +} + interface FilterEdgesForPersistResult { safeEdges: T[] droppedCounts: Record @@ -283,8 +293,8 @@ async function insertAutoConnectEdge( workflowId, sourceBlockId: autoConnectEdge.source, targetBlockId: autoConnectEdge.target, - sourceHandle: autoConnectEdge.sourceHandle || null, - targetHandle: autoConnectEdge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(autoConnectEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(autoConnectEdge.targetHandle), }) logger.debug( `Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}` @@ -736,6 +746,57 @@ async function handleBlockOperationTx( break } + case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: { + if (!payload.id || payload.errorEnabled === undefined) { + throw new Error('Missing required fields for update error enabled operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + errorEnabled: payload.errorEnabled, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block error output: ${payload.id} -> ${payload.errorEnabled}`) + break + } + + case BLOCK_OPERATIONS.UPDATE_RETRY: { + if (!payload.id || payload.retry === undefined) { + throw new Error('Missing required fields for update retry operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + /** + * Persisted verbatim, including a disabled policy, so the numbers a + * builder configured survive switching retry off and back on. NULL stays + * reserved for a block that never had a policy at all; whether a stored + * policy actually runs is decided by `resolveBlockRetryConfig` at + * execution time, never by the column being present. + */ + retry: payload.retry, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block retry: ${payload.id} -> ${payload.retry.enabled}`) + break + } + case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: { if (!payload.id || !payload.canonicalId || !payload.canonicalMode) { throw new Error('Missing required fields for update canonical mode operation') @@ -922,13 +983,15 @@ async function handleBlocksOperationTx( name: block.name as string, positionX: (block.position as { x: number; y: number }).x, positionY: (block.position as { x: number; y: number }).y, - data: (block.data as Record) || {}, + data: (block.data as Record | undefined) || {}, subBlocks: mergedSubBlocks, outputs: (block.outputs as Record) || {}, enabled: (block.enabled as boolean) ?? true, horizontalHandles: (block.horizontalHandles as boolean) ?? true, advancedMode: (block.advancedMode as boolean) ?? false, triggerMode: (block.triggerMode as boolean) ?? false, + errorEnabled: (block.errorEnabled as boolean) ?? false, + retry: (block.retry as Record | undefined) ?? null, height: (block.height as number) || 0, locked: (block.locked as boolean) ?? false, } @@ -948,6 +1011,8 @@ async function handleBlocksOperationTx( horizontalHandles: sql`excluded.horizontal_handles`, advancedMode: sql`excluded.advanced_mode`, triggerMode: sql`excluded.trigger_mode`, + errorEnabled: sql`excluded.error_enabled`, + retry: sql`excluded.retry`, locked: sql`excluded.locked`, height: sql`excluded.height`, subBlocks: sql`excluded.sub_blocks`, @@ -1024,8 +1089,8 @@ async function handleBlocksOperationTx( // blocksById lookup (a plain `tx.select` from `workflowBlocks`) also // sees the blocks this same batch just inserted — reads observe a // transaction's own prior writes. - const candidates: EdgeAddCandidate[] = (edges as Array>).map( - (e) => ({ + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ id: e.id as string, source: e.source as string, target: e.target as string, @@ -1051,8 +1116,8 @@ async function handleBlocksOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -1509,18 +1574,17 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str throw new Error('Missing required fields for add edge operation') } + const candidate = canonicalizeEdgeAddCandidate({ + id: payload.id, + source: payload.source, + target: payload.target, + sourceHandle: payload.sourceHandle ?? null, + targetHandle: payload.targetHandle ?? null, + }) const { safeEdges, droppedCounts, droppedDuplicates } = await filterEdgesForPersist( tx, workflowId, - [ - { - id: payload.id, - source: payload.source, - target: payload.target, - sourceHandle: payload.sourceHandle ?? null, - targetHandle: payload.targetHandle ?? null, - }, - ] + [candidate] ) if (safeEdges.length === 0) { @@ -1534,13 +1598,14 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str break } + const [safeEdge] = safeEdges await tx.insert(workflowEdges).values({ - id: payload.id, + id: safeEdge.id, workflowId, - sourceBlockId: payload.source, - targetBlockId: payload.target, - sourceHandle: payload.sourceHandle || null, - targetHandle: payload.targetHandle || null, + sourceBlockId: safeEdge.source, + targetBlockId: safeEdge.target, + sourceHandle: normalizeWorkflowEdgeSourceHandle(safeEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(safeEdge.targetHandle), }) logger.debug(`Added edge ${payload.id}: ${payload.source} -> ${payload.target}`) @@ -1755,13 +1820,15 @@ async function handleEdgesOperationTx( logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`) - const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => ({ - id: e.id as string, - source: e.source as string, - target: e.target as string, - sourceHandle: (e.sourceHandle as string | null) ?? null, - targetHandle: (e.targetHandle as string | null) ?? null, - })) + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ + id: e.id as string, + source: e.source as string, + target: e.target as string, + sourceHandle: (e.sourceHandle as string | null) ?? null, + targetHandle: (e.targetHandle as string | null) ?? null, + }) + ) const { safeEdges, droppedCounts, droppedDuplicates, droppedCyclic } = await filterEdgesForPersist(tx, workflowId, candidates) @@ -1784,8 +1851,8 @@ async function handleEdgesOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -2136,6 +2203,8 @@ async function handleWorkflowOperationTx( name: block.name, positionX: block.position.x, positionY: block.position.y, + errorEnabled: block.errorEnabled ?? false, + retry: block.retry ?? null, data: block.data || {}, subBlocks: block.subBlocks || {}, outputs: block.outputs || {}, @@ -2152,16 +2221,34 @@ async function handleWorkflowOperationTx( // Insert all edges from the new state if (edges && edges.length > 0) { - const edgeValues = edges.map((edge: any) => ({ + const canonicalEdges = (edges as Array>).map((edge) => + canonicalizeEdgeAddCandidate({ + id: edge.id as string, + source: edge.source as string, + target: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) ?? null, + targetHandle: (edge.targetHandle as string | null) ?? null, + }) + ) + const uniqueEdges = filterUniqueWorkflowEdges(canonicalEdges, []) + const edgeValues = uniqueEdges.map((edge) => ({ id: edge.id, workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: edge.sourceHandle ?? null, + targetHandle: edge.targetHandle ?? null, })) - await tx.insert(workflowEdges).values(edgeValues) + if (uniqueEdges.length < edges.length) { + logger.info(`Dropped ${edges.length - uniqueEdges.length} duplicate edge(s)`, { + operation: WORKFLOW_OPERATIONS.REPLACE_STATE, + }) + } + + if (edgeValues.length > 0) { + await tx.insert(workflowEdges).values(edgeValues) + } } // Insert all loops from the new state diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index c109259f39..4edc05d795 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -7,7 +7,7 @@ * - Edge cases and invalid inputs */ -import { ALL_SOCKET_OPERATIONS } from '@sim/realtime-protocol/constants' +import { ALL_SOCKET_OPERATIONS, BLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' import { expectPermissionAllowed, expectPermissionDenied, @@ -232,6 +232,17 @@ describe('checkRolePermission', () => { expect(adminOnly.length).toBeGreaterThan(0) }) + it('grants write every per-block operation the protocol declares', () => { + // A block operation that reaches this gate is an ordinary editor edit, so the + // write role must hold all of them. Without this, adding a block setting to + // the protocol and forgetting the ACL entry fails silently at runtime: the + // editor applies the change optimistically and the server drops the write. + const denied = Object.values(BLOCK_OPERATIONS).filter( + (operation) => !checkRolePermission('write', operation).allowed + ) + expect(denied).toEqual([]) + }) + it('grants read nothing, so it is trivially a subset of write', () => { const readAllowed = ALL_SOCKET_OPERATIONS.filter( (operation) => checkRolePermission('read', operation).allowed diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 69892a590a..e17678461a 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -29,6 +29,8 @@ const WRITE_OPERATIONS: string[] = [ BLOCK_OPERATIONS.TOGGLE_ENABLED, BLOCK_OPERATIONS.UPDATE_PARENT, BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED, + BLOCK_OPERATIONS.UPDATE_RETRY, BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES, BLOCK_OPERATIONS.TOGGLE_HANDLES, diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index 22b7833876..ddee87cff6 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -868,10 +868,10 @@ export default function PlaygroundPage() { - - + + - + Item 1 diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts new file mode 100644 index 0000000000..6548abe1cb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + * + * The editor's node chrome must be reachable from every surface, including a bare one. + * + * `.rich-markdown-prose` is the document type scale and ink, and a host that paints its own surface + * (the canvas Note card) drops it — so a chrome rule authored under that class silently vanishes + * there. That is how a note's checklists shipped rendering as a native checkbox beside a disc + * bullet, with the label wrapped onto the next line: every `ul[data-type="taskList"]` rule lived on + * the prose class, and the generic `ul` styling was all that was left. + * + * Reads the real, shipped stylesheet rather than a copy. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' + +const EDITOR_CSS_PATH = path.join(__dirname, 'rich-markdown-editor.css') + +/** + * Selector fragments that identify a ProseMirror node or interaction the editor owns and no host + * re-declares — as opposed to typography, which a bare host supplies itself. + */ +const CHROME_MARKERS = [ + 'taskList', + 'ProseMirror-selectednode', + 'ProseMirror-gapcursor', + 'rich-leaf-in-selection', + 'column-resize-handle', + 'selectedCell', + 'code-editor-theme', + 'raw-markdown-', +] as const + +let selectors: string[] = [] + +beforeAll(() => { + const style = document.createElement('style') + style.textContent = readFileSync(EDITOR_CSS_PATH, 'utf-8') + document.head.appendChild(style) + if (!style.sheet) throw new Error('rich-markdown-editor.css did not parse') + selectors = Array.from(style.sheet.cssRules) + .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) + .map((rule) => rule.selectorText) +}) + +describe('rich markdown chrome scoping', () => { + it.each(CHROME_MARKERS)('scopes every %s rule to the shared node class', (marker) => { + const matching = selectors.filter((selector) => selector.includes(marker)) + expect(matching.length).toBeGreaterThan(0) + for (const selector of matching) { + expect(selector).toContain('.rich-markdown-nodes') + expect(selector).not.toContain('.rich-markdown-prose') + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts new file mode 100644 index 0000000000..bc00c7b827 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + * + * Dragging an image to reposition it inside a document must MOVE it, not import it again. + * + * The browser enriches a dragged `` with an image `File`, and TipTap's image node view runs its + * own `dragstart` that bypasses ProseMirror's serialization — so the drop looks exactly like an + * external image drop, and the upload path stored a fresh copy of the image on every nudge while the + * original never moved. + */ +import { type Node as PMNode, Schema } from '@tiptap/pm/model' +import { EditorState, NodeSelection } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' +import { describe, expect, it, vi } from 'vitest' +import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'text*' }, + image: { group: 'block', attrs: { src: {} }, draggable: true }, + text: {}, + }, +}) + +const SRC = '/api/workspaces/ws-1/files/inline?fileId=f1' + +function imageHtml(src: string): string { + return `` +} + +/** A doc of `paragraph, image, paragraph` with the image node-selected, and a view around it. */ +function selectedImageView(): { view: EditorView; dispatched: { state: () => EditorState } } { + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text('before')]), + schema.node('image', { src: SRC }), + schema.node('paragraph', null, [schema.text('after')]), + ]) + let state = EditorState.create({ doc }) + state = state.apply(state.tr.setSelection(NodeSelection.create(state.doc, 8))) + + const view = { + get state() { + return state + }, + posAtCoords: () => ({ pos: 1, inside: 0 }), + dispatch: vi.fn((tr) => { + state = state.apply(tr) + }), + } as unknown as EditorView + + return { view, dispatched: { state: () => state } } +} + +function dropEvent(): DragEvent { + return { clientX: 10, clientY: 10, preventDefault: vi.fn() } as unknown as DragEvent +} + +function imageCount(doc: PMNode): number { + let count = 0 + doc.descendants((node) => { + if (node.type.name === 'image') count += 1 + }) + return count +} + +describe('moveDraggedImageNode', () => { + it('moves the dragged image instead of leaving it to be re-uploaded', () => { + const { view, dispatched } = selectedImageView() + const event = dropEvent() + + expect(moveDraggedImageNode(view, event, { images: [], html: imageHtml(SRC) })).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(imageCount(dispatched.state().doc)).toBe(1) + /* Moved ahead of the paragraph it started after. */ + expect(dispatched.state().doc.firstChild?.type.name).toBe('image') + }) + + it('still claims the drop when the browser also attached the image as a file', () => { + const { view } = selectedImageView() + const file = new File([''], 'shot.png', { type: 'image/png' }) + + expect(moveDraggedImageNode(view, dropEvent(), { images: [file], html: imageHtml(SRC) })).toBe( + true + ) + }) + + it('resolves the rendered src through the host before comparing', () => { + const { view } = selectedImageView() + const rendered = '/api/files/public/tok/inline?fileId=f1' + + expect( + moveDraggedImageNode(view, dropEvent(), { + images: [], + html: imageHtml(rendered), + resolveSrc: () => rendered, + }) + ).toBe(true) + }) + + it('leaves a genuinely new image to the upload path', () => { + const { view } = selectedImageView() + const file = new File([''], 'other.png', { type: 'image/png' }) + + expect( + moveDraggedImageNode(view, dropEvent(), { + images: [file], + html: imageHtml('https://elsewhere.test/other.png'), + }) + ).toBe(false) + }) + + it('leaves a multi-file drop to the upload path', () => { + const { view } = selectedImageView() + const files = [ + new File([''], 'a.png', { type: 'image/png' }), + new File([''], 'b.png', { type: 'image/png' }), + ] + + expect(moveDraggedImageNode(view, dropEvent(), { images: files, html: imageHtml(SRC) })).toBe( + false + ) + }) + + it('ignores a drop with no image selected', () => { + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('hi')])]) + const state = EditorState.create({ doc }) + const view = { + state, + posAtCoords: () => ({ pos: 1 }), + dispatch: vi.fn(), + } as unknown as EditorView + + expect(moveDraggedImageNode(view, dropEvent(), { images: [], html: imageHtml(SRC) })).toBe( + false + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts new file mode 100644 index 0000000000..904f2cd5ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts @@ -0,0 +1,66 @@ +import { Fragment, Slice } from '@tiptap/pm/model' +import { NodeSelection } from '@tiptap/pm/state' +import { dropPoint } from '@tiptap/pm/transform' +import type { EditorView } from '@tiptap/pm/view' +import { htmlReferencesSrc } from './image-paste' + +interface MoveDraggedImageOptions { + /** Image files on the drop, from `extractImageFiles`. */ + images: File[] + /** The drop's `text/html`, which the browser enriches with the dragged node's rendered src. */ + html: string + /** + * Maps a node's stored `src` to the URL actually rendered into ``, when the host rewrites + * it (the file editor's display-layer resolve). Omit where stored and rendered are the same. + */ + resolveSrc?: (src: string | undefined) => string | undefined +} + +/** + * Repositions an image the user dragged from inside this editor, returning true when it consumed + * the drop. + * + * TipTap's image node view runs its own `dragstart`, which bypasses ProseMirror's clipboard + * serialization — no PM `text/html`, and `view.dragging` never set — so neither ProseMirror's default + * move nor the `slice` argument to `handleDrop` sees anything. What survives is a NodeSelection on the + * dragged image plus the browser's native enrichment, whose html carries the absolute rendered URL of + * exactly that node. That pair is the signal, and without acting on it the drop falls through to the + * upload path and stores a second copy of an image the document already has. + * + * The move is the same shape as ProseMirror's own: compute the drop point against the pre-delete doc, + * delete the source, then map the insert position through that delete. A null `dropPoint` (no valid + * insertion point) is a handled no-op — the node stays put, still selected — rather than a raw-position + * fallback, which `tr.insert` can throw on. + * + * The gate accepts at most one file rather than exactly one: some drag transports carry the html + * alone. A genuinely external drop can never reference the currently selected node's own rendered src. + */ +export function moveDraggedImageNode( + view: EditorView, + event: DragEvent, + { images, html, resolveSrc }: MoveDraggedImageOptions +): boolean { + const { selection } = view.state + if (images.length > 1) return false + if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'image') return false + + const src = selection.node.attrs.src + const rendered = typeof src === 'string' ? (resolveSrc?.(src) ?? src) : undefined + if (!htmlReferencesSrc(html, rendered)) return false + + event.preventDefault() + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }) + if (!coords) return true + + const node = selection.node + const tr = view.state.tr + const insertPos = dropPoint(view.state.doc, coords.pos, new Slice(Fragment.from(node), 0, 0)) + if (insertPos === null) return true + + tr.delete(selection.from, selection.to) + const mapped = tr.mapping.map(insertPos) + tr.insert(mapped, node) + tr.setSelection(NodeSelection.create(tr.doc, mapped)) + view.dispatch(tr.scrollIntoView()) + return true +} 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 1af8faa0bd..b148b2904f 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 @@ -1,3 +1,18 @@ +/* + * Two classes, both on the ProseMirror root, splitting what a host may own from what it may not. + * + * `.rich-markdown-nodes` is the editor's node chrome: task-list layout and checkboxes, selection + * rings, the gap cursor, image and divider treatment, the highlight mark, table resize affordances, + * collaborative carets. It is the same on every surface and no host re-declares it. + * + * `.rich-markdown-prose` is the document type scale and ink — sizes, weights, colours, block rhythm. + * A host that paints its own surface (the canvas Note card, which has its own fill and text colour) + * takes `surface='bare'`, drops this class, and supplies its own typography instead. + * + * Keep new rules on the right side of that line. A chrome rule that lands in `prose` silently + * disappears on every bare surface: that is how a note's checklists ended up rendering as a native + * checkbox next to a disc bullet, with the label wrapped onto the following line. + */ .rich-markdown-prose { flex: 1 1 auto; outline: none; @@ -10,7 +25,7 @@ overflow-wrap: anywhere; } -.rich-markdown-prose img { +.rich-markdown-nodes img { max-width: 100%; height: auto; border-radius: 8px; @@ -19,7 +34,7 @@ /* One consistent ring for every node that can only be selected as a whole (divider, image, code block, table) — so a mouse click and a keyboard NodeSelection look identical. */ -.rich-markdown-prose .ProseMirror-selectednode { +.rich-markdown-nodes .ProseMirror-selectednode { outline: 2px solid var(--brand-secondary); outline-offset: 2px; border-radius: 4px; @@ -27,11 +42,11 @@ /* An image is its own framed element; ring the image itself so the indicator hugs the picture rather than the node's bounding box, which can be far wider/taller than a small image. */ -.rich-markdown-prose .ProseMirror-selectednode:has(img) { +.rich-markdown-nodes .ProseMirror-selectednode:has(img) { outline: none; } -.rich-markdown-prose .ProseMirror-selectednode img { +.rich-markdown-nodes .ProseMirror-selectednode img { outline: 2px solid var(--brand-secondary); outline-offset: 2px; } @@ -39,8 +54,8 @@ /* The inline mention chip isn't a block leaf, so it skips the heavy outline ring above and just uses the same native text-selection highlight as the surrounding prose. ProseMirror puts `selectednode` on the node-view wrapper, so match it via `:has` (the chip span itself when it's the selected node). */ -.rich-markdown-prose .mention-chip.ProseMirror-selectednode, -.rich-markdown-prose .ProseMirror-selectednode:has(.mention-chip) { +.rich-markdown-nodes .mention-chip.ProseMirror-selectednode, +.rich-markdown-nodes .ProseMirror-selectednode:has(.mention-chip) { outline: none; } @@ -128,7 +143,7 @@ /* Render the gap cursor (e.g. above a leading divider) as a normal vertical caret rather than ProseMirror's default short horizontal bar, which reads as a stray underscore. */ -.rich-markdown-prose .ProseMirror-gapcursor::after { +.rich-markdown-nodes .ProseMirror-gapcursor::after { top: 0; width: 2px; height: 1.25em; @@ -140,7 +155,7 @@ empty space the view gives no hint about. Hide it there (matching Linear): the gap cursor stays functional — typing still inserts a block between them — it just isn't drawn. Leading/trailing gaps keep theirs. The keymap plugin sets `data-gap-between-leaves` when both neighbours are leaves. */ -.rich-markdown-prose[data-gap-between-leaves] .ProseMirror-gapcursor::after { +.rich-markdown-nodes[data-gap-between-leaves] .ProseMirror-gapcursor::after { display: none; } @@ -157,7 +172,7 @@ list-style: decimal; } -.rich-markdown-prose li > p { +.rich-markdown-nodes li > p { margin: 0; } @@ -165,38 +180,44 @@ color: var(--text-primary); } -.rich-markdown-prose ul[data-type="taskList"] { +/* A checklist is not a bulleted list, so this has to outrank whatever the host set on `ul` — hence + the doubled-up root selector. Both classes are always on the ProseMirror root; the extra class + buys the specificity to beat a host's own `ul` rule, which would otherwise tie and be decided by + stylesheet order. Losing that tie puts a disc bullet beside every checkbox. */ +.ProseMirror.rich-markdown-nodes ul[data-type="taskList"] { list-style: none; padding-left: 0; } -.rich-markdown-prose ul[data-type="taskList"] li { +.rich-markdown-nodes ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5em; } -/* One line tall with the box centered, so it aligns with the item's first line. */ -.rich-markdown-prose ul[data-type="taskList"] li > label { +/* One line tall with the box centered, so it aligns with the item's first line. The height has to + match whatever line-height the host set — the default is the prose 25px at 15px font, and a host + on a different scale (the canvas Note) overrides the variable rather than this rule. */ +.rich-markdown-nodes ul[data-type="taskList"] li > label { display: flex; align-items: center; - height: 1.6667em; /* = the prose 25px line-height at 15px font */ + height: var(--rich-markdown-line-height, 1.6667em); flex-shrink: 0; user-select: none; } -.rich-markdown-prose ul[data-type="taskList"] li > div { +.rich-markdown-nodes ul[data-type="taskList"] li > div { flex: 1 1 auto; min-width: 0; } /* TaskItem nests content as li > div > p, which the `li > p` reset misses, leaving UA margins. */ -.rich-markdown-prose ul[data-type="taskList"] li > div > p { +.rich-markdown-nodes ul[data-type="taskList"] li > div > p { margin: 0; } /* Match the design-system Checkbox (emcn) rather than the platform-native control. */ -.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"] { +.rich-markdown-nodes ul[data-type="taskList"] input[type="checkbox"] { appearance: none; -webkit-appearance: none; display: inline-grid; @@ -210,12 +231,12 @@ cursor: pointer; } -.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked { +.rich-markdown-nodes ul[data-type="taskList"] input[type="checkbox"]:checked { background-color: var(--text-primary); border-color: var(--text-primary); } -.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked::after { +.rich-markdown-nodes ul[data-type="taskList"] input[type="checkbox"]:checked::after { content: ""; width: 10px; height: 10px; @@ -275,15 +296,15 @@ only wrap when the line-wrap toggle sets data-wrap, breaking long unbroken tokens too. The `overflow-wrap`/`word-break` resets undo the editor-wide `overflow-wrap: anywhere`, which would otherwise still break a long unbroken token even under `white-space: pre`. */ -.rich-markdown-prose pre.code-editor-theme, -.rich-markdown-prose pre.code-editor-theme code { +.rich-markdown-nodes pre.code-editor-theme, +.rich-markdown-nodes pre.code-editor-theme code { white-space: pre; overflow-wrap: normal; word-break: normal; } -.rich-markdown-prose pre.code-editor-theme[data-wrap="true"], -.rich-markdown-prose pre.code-editor-theme[data-wrap="true"] code { +.rich-markdown-nodes pre.code-editor-theme[data-wrap="true"], +.rich-markdown-nodes pre.code-editor-theme[data-wrap="true"] code { white-space: pre-wrap; overflow-wrap: anywhere; } @@ -304,8 +325,8 @@ comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts). Same neutral surface as `code`/`pre` below (no color tint — a tint reads as a warning/error state, which isn't the signal here); the hover "Raw HTML"/"Footnote" badge is what conveys "not interpreted". */ -.rich-markdown-prose .raw-markdown-block, -.rich-markdown-prose .raw-markdown-inline { +.rich-markdown-nodes .raw-markdown-block, +.rich-markdown-nodes .raw-markdown-inline { font-family: var(--font-martian-mono, ui-monospace, monospace); font-size: 0.875em; color: var(--text-muted); @@ -314,22 +335,22 @@ overflow-wrap: anywhere; } -.dark .rich-markdown-prose .raw-markdown-block, -.dark .rich-markdown-prose .raw-markdown-inline { +.dark .rich-markdown-nodes .raw-markdown-block, +.dark .rich-markdown-nodes .raw-markdown-inline { background: var(--code-bg); } -.rich-markdown-prose .raw-markdown-block { +.rich-markdown-nodes .raw-markdown-block { border-radius: 8px; padding: 0.75rem 1rem; } -.rich-markdown-prose .raw-markdown-inline { +.rich-markdown-nodes .raw-markdown-inline { border-radius: 4px; padding: 0.0625rem 0.3rem; } -.rich-markdown-prose hr { +.rich-markdown-nodes hr { border: none; border-top: var(--border-width) solid var(--border); margin: 1.5em 0; @@ -339,17 +360,17 @@ highlight skips leaf nodes (they hold no text), so the keymap's decoration paints the band itself. A divider is a void
(no pseudo-elements), so a box-shadow spreads the selection band around its hairline without shifting layout; an image rings instead. */ -.rich-markdown-prose hr.rich-leaf-in-selection { +.rich-markdown-nodes hr.rich-leaf-in-selection { box-shadow: 0 0 0 0.4em var(--selection-bg); border-radius: 1px; } -.dark .rich-markdown-prose hr.rich-leaf-in-selection { +.dark .rich-markdown-nodes hr.rich-leaf-in-selection { box-shadow: 0 0 0 0.4em var(--selection-dark); } -.rich-markdown-prose .rich-leaf-in-selection:has(img) img, -.rich-markdown-prose img.rich-leaf-in-selection { +.rich-markdown-nodes .rich-leaf-in-selection:has(img) img, +.rich-markdown-nodes img.rich-leaf-in-selection { outline: 2px solid var(--selection-bg); outline-offset: 2px; border-radius: 4px; @@ -357,21 +378,25 @@ /* Borders, padding, typography, and header fill come from document-table.css — the chrome shared with the CSV/XLSX previews. Only the editor-specific bits live here: `table-layout: fixed` is - required by prosemirror-tables' column-resizing plugin, the block margin is prose rhythm, and - `overflow: visible` lets a collaborator's caret name label escape the table box instead of being - clipped (fixed layout means columns can't exceed the table width, so nothing else needs clipping). */ -.rich-markdown-prose table { + required by prosemirror-tables' column-resizing plugin, and `overflow: visible` lets a + collaborator's caret name label escape the table box instead of being clipped (fixed layout means + columns can't exceed the table width, so nothing else needs clipping). */ +.rich-markdown-nodes table { table-layout: fixed; - margin: 1rem 0; overflow: visible; } -.rich-markdown-prose th > p, -.rich-markdown-prose td > p { +/* The block margin is prose rhythm, so it stays with the type scale. */ +.rich-markdown-prose table { + margin: 1rem 0; +} + +.rich-markdown-nodes th > p, +.rich-markdown-nodes td > p { margin: 0; } -.rich-markdown-prose .selectedCell::after { +.rich-markdown-nodes .selectedCell::after { content: ""; position: absolute; inset: 0; @@ -380,7 +405,7 @@ pointer-events: none; } -.rich-markdown-prose .column-resize-handle { +.rich-markdown-nodes .column-resize-handle { position: absolute; right: -2px; top: 0; @@ -395,7 +420,7 @@ * while the pointer is over a column boundary; without this rule the handle shows but the cursor * never changes to the resize affordance. */ -.rich-markdown-prose.resize-cursor { +.rich-markdown-nodes.resize-cursor { cursor: col-resize; } @@ -414,7 +439,7 @@ * horizontal padding is cancelled by an equal negative margin so the tint bleeds slightly past the * text without ever shifting the text (or any following text) as the highlight is applied/removed. */ -.rich-markdown-prose mark { +.rich-markdown-nodes mark { background-color: rgba(255, 212, 0, 0.4); color: inherit; border-radius: 2px; @@ -452,7 +477,7 @@ * caret (and its label) above table cell backgrounds so a caret inside a table * cell is not hidden behind adjacent cells. */ -.rich-markdown-prose .collaboration-carets__caret { +.rich-markdown-nodes .collaboration-carets__caret { /* Zero inline footprint: a positioned anchor with NO width/border/margin, so inserting or moving this * inline widget never reflows the surrounding text. The visible bar, dormant cap (::before), name label, * and hover slop (::after) are all positioned relative to this anchor and are out of the inline flow. */ @@ -463,7 +488,7 @@ /* The visible caret bar — absolutely positioned so it draws over the text without occupying inline * width. `left: -1px` centers the 2px bar on the cursor position; top/bottom span the line box height. */ -.rich-markdown-prose .collaboration-carets__bar { +.rich-markdown-nodes .collaboration-carets__bar { position: absolute; top: -0.1em; bottom: -0.1em; @@ -478,7 +503,7 @@ * tables/canvas tags) so the whole presence system reads as one language. It signals * "someone's here — hover for who", and fades out when the full name tag takes over * (peer active or on hover). */ -.rich-markdown-prose .collaboration-carets__caret::before { +.rich-markdown-nodes .collaboration-carets__caret::before { content: ""; position: absolute; /* Seat the cap's square bottom-left corner on the pole's top-left, flush like a flag on its pole. @@ -493,8 +518,8 @@ transition: opacity 0.2s ease; } -.rich-markdown-prose .collaboration-carets__caret--active::before, -.rich-markdown-prose .collaboration-carets__caret:hover::before { +.rich-markdown-nodes .collaboration-carets__caret--active::before, +.rich-markdown-nodes .collaboration-carets__caret:hover::before { opacity: 0; } @@ -504,7 +529,7 @@ * height and over the cap — nothing visible changes, only the pointer area. `pointer-events: * auto` is required so it catches the hover; kept narrow so it barely intrudes on selecting * text next to a remote caret. */ -.rich-markdown-prose .collaboration-carets__caret::after { +.rich-markdown-nodes .collaboration-carets__caret::after { content: ""; position: absolute; top: -4px; @@ -519,7 +544,7 @@ * one system. `--surface-1` is the base surface token (readable on every assigned * identity color in both themes), not a hardcoded value. Hidden by default; the * show/fade is driven by the `--active` class (see caret-presence.ts) and hover. */ -.rich-markdown-prose .collaboration-carets__label { +.rich-markdown-nodes .collaboration-carets__label { position: absolute; top: -1.4em; left: -1px; @@ -541,14 +566,14 @@ transition: opacity 0.2s ease; } -.rich-markdown-prose .collaboration-carets__caret--active .collaboration-carets__label, -.rich-markdown-prose .collaboration-carets__caret:hover .collaboration-carets__label { +.rich-markdown-nodes .collaboration-carets__caret--active .collaboration-carets__label, +.rich-markdown-nodes .collaboration-carets__caret:hover .collaboration-carets__label { opacity: 1; } /* Near the editor's right edge the label is flipped to the caret's left so it never * runs off (JS toggles `--flip` after measuring); mirror the tag's notch corner. */ -.rich-markdown-prose .collaboration-carets__caret--flip .collaboration-carets__label { +.rich-markdown-nodes .collaboration-carets__caret--flip .collaboration-carets__label { left: auto; right: -1px; border-radius: 2px 2px 0 2px; @@ -556,7 +581,7 @@ /* Remote text selection: a rounded translucent tint of the collaborator's identity * color (the alpha fill is set inline by selectionRender). */ -.rich-markdown-prose .collaboration-carets__selection { +.rich-markdown-nodes .collaboration-carets__selection { border-radius: 2px; pointer-events: none; } 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 f00c89f7a0..4bc6285eed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -5,9 +5,6 @@ import { cn, toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' import type { Extensions, JSONContent } from '@tiptap/core' import { isChangeOrigin } from '@tiptap/extension-collaboration' -import { Fragment, Slice } from '@tiptap/pm/model' -import { NodeSelection } from '@tiptap/pm/state' -import { dropPoint } from '@tiptap/pm/transform' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' @@ -42,11 +39,11 @@ import { nextCollabReadiness } from './collaboration/readiness' import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration' import { createMarkdownEditorExtensions } from './editor-extensions' import { findHeadingPos } from './heading-anchors' +import { moveDraggedImageNode } from './image-drag-move' import { extractImageFiles, extractImgSrcs, findHostedImageAttrs, - htmlReferencesSrc, shouldSkipFileUpload, } from './image-paste' import { @@ -114,7 +111,7 @@ function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { immediatelyRender: true, shouldRerenderOnTransaction: false, content, - editorProps: { attributes: { class: 'rich-markdown-prose' } }, + editorProps: { attributes: { class: 'rich-markdown-nodes rich-markdown-prose' } }, }) return } @@ -540,7 +537,10 @@ export function LoadedRichMarkdownEditor({ shouldRerenderOnTransaction: false, content: initialContent, editorProps: { - attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' }, + attributes: { + class: 'rich-markdown-nodes rich-markdown-prose', + 'data-owned-shortcuts': 'Mod+K', + }, handleKeyDown: (_view, event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' if (!isSaveShortcut) return false @@ -615,24 +615,11 @@ export function LoadedRichMarkdownEditor({ * the browser doesn't navigate away from the editor; internal text drags carry no files and fall * through to the default behavior. * - * Drag-REORDER of an image node is the deceptive case. TipTap's node-view dragstart bypasses - * ProseMirror's own drag serialization entirely — no PM `text/html`, no `view.dragging` — but it - * DOES NodeSelect the dragged image; what the drop carries instead is the browser's native - * enrichment for a dragged ``: an image `File` plus `text/html` whose src is the ABSOLUTE - * rendered URL of that exact node. So when the drop's html points at the currently-selected image - * node ({@link htmlReferencesSrc}), this drop IS that node being moved, and the move must be - * performed here: uploading would duplicate it (the original never moves), and falling through to - * ProseMirror is no better — with `view.dragging` unset its default drop PARSES the html into a - * copy (persisting the display-layer src, which share/export tracking don't recognize) and never - * deletes the original. The gate accepts at most one file (not exactly one): some drag transports - * (e.g. CDP-driven input) carry the html alone, and a genuinely external drop can never reference - * the currently-selected node's own resolved src. - * - * The move itself is the same shape as ProseMirror's own: compute the drop point on the - * pre-delete doc, delete the source, map the insert position through that delete. A null - * `dropPoint` (no valid insertion point) is a handled no-op — the node stays put, still - * selected — never a raw-position fallback, which `tr.insert` could throw on (PM's own null - * fallback is only safe because it uses the forgiving `replaceRangeWith`). + * Drag-REORDER of an image node is the deceptive case, and {@link moveDraggedImageNode} owns it — + * uploading would duplicate the image (the original never moves), and falling through to + * ProseMirror is no better, since with `view.dragging` unset its default drop PARSES the html into + * a copy (persisting the display-layer src, which share/export tracking don't recognize) and never + * deletes the original. * * PM-serialized drags (a text selection spanning an image, dragged from a textblock) still reach * the `shouldSkipFileUpload` bail below: PM set `view.dragging` for those itself, so its default @@ -642,29 +629,13 @@ export function LoadedRichMarkdownEditor({ if (!view.editable) return false const images = extractImageFiles(event.dataTransfer) const html = event.dataTransfer?.getData('text/html') ?? '' - const { selection } = view.state if ( - images.length <= 1 && - selection instanceof NodeSelection && - selection.node.type.name === 'image' && - htmlReferencesSrc(html, resolveImageSrcRef.current(selection.node.attrs.src)) + moveDraggedImageNode(view, event, { + images, + html, + resolveSrc: resolveImageSrcRef.current, + }) ) { - event.preventDefault() - const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }) - if (!coords) return true - const node = selection.node - const tr = view.state.tr - const insertPos = dropPoint( - view.state.doc, - coords.pos, - new Slice(Fragment.from(node), 0, 0) - ) - if (insertPos === null) return true - tr.delete(selection.from, selection.to) - const mapped = tr.mapping.map(insertPos) - tr.insert(mapped, node) - tr.setSelection(NodeSelection.create(tr.doc, mapped)) - view.dispatch(tr.scrollIntoView()) return true } if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== 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 index f52206c3bc..559afc129c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -5,6 +5,8 @@ import { ChipTextarea, chipFieldSurfaceClass, cn } from '@sim/emcn' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' import { createMarkdownEditorExtensions } from './editor-extensions' +import { moveDraggedImageNode } from './image-drag-move' +import { extractImageFiles, isInlineRouteSrc, shouldSkipFileUpload } from './image-paste' import { applyFrontmatter, postProcessSerializedMarkdown, @@ -20,6 +22,17 @@ import '@sim/emcn/components/code/code.css' import '../document-table.css' import './rich-markdown-editor.css' +/** + * Sends the formatting toolbar back to its body portal instead of anchoring it inside the editor's + * container. + * + * A field or a full page gives the toolbar a bounded pane to sit in and scroll with, and being + * clipped at that pane's edge is the point. A bare host has no such pane — the canvas Note card is + * 320px wide and clips its own overflow, so an in-card toolbar would be cropped to a stub. A null + * container is how {@link EditorBubbleMenu} spells "portal to the body". + */ +const BODY_PORTAL: React.RefObject = { current: null } + interface RichMarkdownFieldProps { /** Current markdown value. Seeds the editor once on mount; external changes only apply while {@link isStreaming}. */ value: string @@ -31,6 +44,13 @@ interface RichMarkdownFieldProps { /** True while `value` is being pushed in externally (AI generation) — the editor turns read-only and mirrors each update. */ isStreaming?: boolean autoFocus?: boolean + /** + * Viewport point to place the caret at on mount, instead of the document end. + * For hosts whose read view sits under a click-to-edit overlay: the overlay + * consumes the click that opens editing, so without the point the caret can + * only land at the end and the user's aim is lost. + */ + autoFocusAt?: { clientX: number; clientY: number } | null /** Min height of the editor box in px. */ minHeight?: number /** @@ -46,12 +66,45 @@ interface RichMarkdownFieldProps { workspaceId?: string /** Force the `@` tag-insertion menu off even with a workspace set (existing tags still render). */ disableTagging?: boolean + /** + * Uploads a pasted or dropped image and returns its hosted URL, or null on + * failure. Providing this enables image paste/drop; without it, file drops + * are swallowed so the browser cannot navigate to the dropped file. The + * expected implementation is the workspace-file pipeline — the same + * presigned-S3 upload and `/api/workspaces/{id}/files/inline` URL shape the + * file editor persists — so every embedded image stays behind workspace + * auth and inside the existing storage lifecycle. + */ + uploadImage?: (file: File) => Promise<{ url: string; alt: string } | null> /** * 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 + /** + * Chrome around the editor. + * + * `'field'` is the bordered chip-field box every form surface uses. `'bare'` + * drops the box, its padding and its default height so a host that already + * owns a surface — the canvas Note card, which paints its own fill and text + * colour — renders the editor inline instead of nesting a second field + * inside it. Both surfaces get the same editor and the same floating menus. + */ + surface?: 'field' | 'bare' + /** + * Typography for a `'bare'` surface, where the host owns the type scale and + * colour. Ignored by `'field'`, which keeps the shared prose styling. + */ + proseClassName?: string + /** + * Classes for the contenteditable root itself on a `'bare'` surface. Anything + * that must beat a rule targeting that element directly belongs here rather + * than in {@link proseClassName} — a global base rule pins `caret-color` on + * `[contenteditable="true"]`, and a declaration on the element always beats a + * value inherited from an ancestor, however specific that ancestor's is. + */ + editorClassName?: string } /** @@ -66,14 +119,22 @@ function LoadedRichMarkdownField({ disabled = false, isStreaming = false, autoFocus = false, - minHeight = 140, + autoFocusAt = null, + minHeight, maxHeight, error = false, workspaceId, disableTagging, onPasteText, + uploadImage, + surface = 'field', + proseClassName, + editorClassName, }: RichMarkdownFieldProps) { const containerRef = useRef(null) + const isBare = surface === 'bare' + /* A field keeps its 140px floor; a bare surface is sized by its host. */ + const boxMinHeight = minHeight ?? (isBare ? undefined : 140) /** * Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. Split @@ -87,6 +148,51 @@ function LoadedRichMarkdownField({ onChangeRef.current = onChange const onPasteTextRef = useRef(onPasteText) onPasteTextRef.current = onPasteText + const uploadImageRef = useRef(uploadImage) + uploadImageRef.current = uploadImage + const autoFocusAtRef = useRef(autoFocusAt) + const editorInstanceRef = useRef>(null) + + /** + * The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret + * position captured when the command ran, so the upload inserts where `/Image` was typed. + */ + const imageInputRef = useRef(null) + const pendingImagePosRef = useRef(null) + + /** + * Sequential upload-then-insert, mirroring the file editor's own image flow: + * each image inserts at the evolving position so a multi-image paste lands in + * order, and a failed upload skips its insert without aborting the rest. The + * upload mutation owns user feedback. + */ + const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => + Promise.resolve() + ) + insertImagesRef.current = async (images, at) => { + const upload = uploadImageRef.current + const owner = editorInstanceRef.current + if (!upload || !owner) return + let position = at + for (const image of images) { + const result = await upload(image).catch(() => null) + /* Bail if the editor unmounted (note closed) while the upload ran. */ + if (!result || editorInstanceRef.current !== owner || owner.isDestroyed) continue + const safePosition = Math.min(position, owner.state.doc.content.size) + try { + owner + .chain() + .insertContentAt(safePosition, { + type: 'image', + attrs: { src: result.url, alt: result.alt }, + }) + .run() + position = owner.state.selection.to + } catch { + position = owner.state.doc.content.size + } + } + } /** * The original value verbatim, plus its canonical serialization. The editor only ever emits canonical @@ -104,17 +210,38 @@ function LoadedRichMarkdownField({ extensions, editable: !disabled && !isStreaming, enablePasteRules: false, - autofocus: autoFocus ? 'end' : false, + autofocus: autoFocusAt ? false : autoFocus ? 'end' : false, immediatelyRender: false, shouldRerenderOnTransaction: false, content: initialContent, editorProps: { attributes: { - class: 'rich-markdown-prose rich-markdown-field-prose', + /* + * `rich-markdown-nodes` carries the editor's node chrome and is not the + * host's to own, so both surfaces take it. The prose classes pin the + * field's ink and type ramp — `--text-primary` at 15px/25px, then + * 14px/22px from the field layer — which a bare host supplies itself + * (the Note card matches its rendered view via `proseClassName`), so on + * that surface they would recolor the text and shift its metrics the + * moment editing opens. + */ + class: cn( + 'rich-markdown-nodes', + isBare ? editorClassName : 'rich-markdown-prose rich-markdown-field-prose' + ), // Claim ⌘K so the bubble-menu link editor wins over the global search palette. 'data-owned-shortcuts': 'Mod+K', }, - handlePaste: (_view, event) => { + handlePaste: (view, event) => { + const images = uploadImageRef.current ? extractImageFiles(event.clipboardData) : [] + /* Copying an image already in the document puts its file on the clipboard too. Let the + html through instead of uploading a second copy of something already hosted. */ + const clipboardHtml = event.clipboardData?.getData('text/html') ?? '' + if (images.length > 0 && !shouldSkipFileUpload(images, clipboardHtml, isInlineRouteSrc)) { + event.preventDefault() + void insertImagesRef.current(images, view.state.selection.from) + return true + } const handler = onPasteTextRef.current if (!handler) return false const text = event.clipboardData?.getData('text/plain') @@ -122,17 +249,37 @@ function LoadedRichMarkdownField({ return handler(text) }, /** - * The field has no image upload; swallow any file drop so the browser doesn't navigate to the - * dropped file and tear down the modal. Internal text drags carry no files and fall through. + * Mirrors the file editor's order: reposition an image dragged from inside the document, then + * bail on a same-page copy of an already-hosted one so ProseMirror inserts it from the html + * instead of uploading a duplicate, then upload anything genuinely new. Any remaining file drop + * is swallowed so the browser doesn't navigate to it and tear down the host; internal text drags + * carry no files and fall through. */ - handleDrop: (_view, event) => { + handleDrop: (view, event) => { + const html = event.dataTransfer?.getData('text/html') ?? '' + const images = uploadImageRef.current ? extractImageFiles(event.dataTransfer) : [] + if (moveDraggedImageNode(view, event, { images, html })) return true + if (shouldSkipFileUpload(images, html, isInlineRouteSrc)) return false if (event.dataTransfer?.files.length) { event.preventDefault() + if (images.length > 0) { + const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos + void insertImagesRef.current(images, dropPos ?? view.state.selection.from) + } return true } return false }, }, + /* Resolved after creation, not via `autofocus`: mapping a point to a + document position needs the editor's DOM laid out. */ + onCreate: ({ editor }) => { + editorInstanceRef.current = editor + const point = autoFocusAtRef.current + if (!point) return + const resolved = editor.view.posAtCoords({ left: point.clientX, top: point.clientY }) + editor.commands.focus(resolved ? resolved.pos : 'end') + }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md @@ -176,6 +323,22 @@ function LoadedRichMarkdownField({ if (editor.isEditable !== !disabled) editor.setEditable(!disabled) }, [editor, value, isStreaming, disabled]) + /** + * Wires the `/Image` slash command to the hidden picker, but only for a host that gave us an + * uploader — the command hides itself when this storage slot stays null, which is what keeps it + * out of the modal field editors that have nowhere to put an image. + */ + useEffect(() => { + if (!editor || !uploadImage) return + editor.storage.slashCommand.insertImage = (at: number) => { + pendingImagePosRef.current = at + imageInputRef.current?.click() + } + return () => { + editor.storage.slashCommand.insertImage = null + } + }, [editor, uploadImage]) + useEditorMentions(editor, workspaceId, { disableTagging }) return ( @@ -184,25 +347,59 @@ function LoadedRichMarkdownField({ className={cn( // `relative` makes this the positioning context for the bubble menu, which is // appended here and absolutely positioned so it tracks the selection through scroll. - 'relative flex flex-col px-3 py-2', + 'relative flex flex-col', // Only a capped box scrolls itself. Uncapped, the box grows and the page // scrolls — making it a scroll container anyway would clip the bubble // menu against an edge that never moves. maxHeight !== undefined && 'overflow-y-auto', - chipFieldSurfaceClass, - error && 'border-[var(--text-error)]', - !disabled && !isStreaming && 'cursor-text', - // Match the chip fields' disabled chrome (dimmed, not copyable) while - // keeping the container scrollable; streaming stays full-strength. - disabled && !isStreaming && 'select-none opacity-50' + !isBare && [ + 'px-3 py-2', + chipFieldSurfaceClass, + error && 'border-[var(--text-error)]', + !disabled && !isStreaming && 'cursor-text', + // Match the chip fields' disabled chrome (dimmed, not copyable) while + // keeping the container scrollable; streaming stays full-strength. + disabled && !isStreaming && 'select-none opacity-50', + ], + isBare && 'min-h-full w-full' )} - style={{ minHeight, maxHeight }} + style={{ minHeight: boxMinHeight, maxHeight }} > - {editor && } + {editor && ( + + )} {editor && } + {uploadImage && ( + { + const input = event.currentTarget + const images = Array.from(input.files ?? []).filter((file) => + file.type.startsWith('image/') + ) + const at = + pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 + pendingImagePosRef.current = null + input.value = '' + if (images.length > 0) void insertImagesRef.current(images, at) + }} + /> + )} ) @@ -220,14 +417,18 @@ function RawMarkdownField({ placeholder, disabled = false, isStreaming = false, - minHeight = 140, + minHeight, maxHeight, error = false, onPasteText, + surface = 'field', + proseClassName, }: RichMarkdownFieldProps) { // Disabled-look without the `disabled` attribute — a disabled textarea is // inert to wheel/scrollbar, but locked content must stay scrollable. const lockedView = disabled && !isStreaming + const isBare = surface === 'bare' + const boxMinHeight = minHeight ?? (isBare ? undefined : 140) /** * Uncapped, the textarea grows with its content so it matches the WYSIWYG @@ -244,7 +445,7 @@ function RawMarkdownField({ const measure = () => { el.style.height = 'auto' - el.style.height = `${Math.max(el.scrollHeight, minHeight)}px` + el.style.height = `${Math.max(el.scrollHeight, boxMinHeight ?? 0)}px` } measure() @@ -254,24 +455,51 @@ function RawMarkdownField({ const observer = new ResizeObserver(measure) observer.observe(el) return () => observer.disconnect() - }, [autoGrow, value, minHeight]) + }, [autoGrow, value, boxMinHeight]) + + const handlePaste = (event: React.ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain') + if (text && onPasteText?.(text)) event.preventDefault() + } + + /* A bare host paints its own surface, so the raw fallback is a plain + textarea inheriting the host's colour — a chip field nested inside a Note + card would draw a second, conflicting surface. */ + if (isBare) { + return ( +