feat(workflows): new workflow block card, progress indicator, colors, dsl for natural language preview, retry configs (#6458)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* update loader animation

* change the loader

---------

Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Waleed <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2026-08-10 19:21:32 -07:00
committed by GitHub
co-authored by Claude Opus 5 andresdjasso Waleed
parent f5c06d4895
commit 245ad1bd46
500 changed files with 75145 additions and 1968 deletions
+36 -1
View File
@@ -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)
+36 -1
View File
@@ -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)
+36 -1
View File
@@ -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)
+122 -35
View File
@@ -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<T> {
safeEdges: T[]
droppedCounts: Record<string, number>
@@ -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<string, unknown>) || {},
data: (block.data as Record<string, unknown> | undefined) || {},
subBlocks: mergedSubBlocks,
outputs: (block.outputs as Record<string, unknown>) || {},
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<string, unknown> | 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<Record<string, unknown>>).map(
(e) => ({
const candidates: EdgeAddCandidate[] = (edges as Array<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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
@@ -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
@@ -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,
+3 -3
View File
@@ -868,10 +868,10 @@ export default function PlaygroundPage() {
</PopoverContent>
</Popover>
</VariantRow>
<VariantRow label='secondary variant'>
<Popover variant='secondary'>
<VariantRow label='inverted color scheme'>
<Popover colorScheme='inverted'>
<PopoverTrigger asChild>
<Button variant='secondary'>Secondary Popover</Button>
<Button variant='secondary'>Inverted Popover</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverItem>Item 1</PopoverItem>
@@ -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')
}
})
})
@@ -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 `<img>` 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 `<img src="${src}">`
}
/** 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
)
})
})
@@ -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 `<img src>`, 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
}
@@ -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 <hr> (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;
}
@@ -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 <EditorContent editor={editor} className={EDITOR_SURFACE_CLASS} />
}
@@ -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 `<img>`: 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)) {
@@ -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<HTMLDivElement | null> = { 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<HTMLDivElement>(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<ReturnType<typeof useEditor>>(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<HTMLInputElement>(null)
const pendingImagePosRef = useRef<number | null>(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<void>>(() =>
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 && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && (
<EditorBubbleMenu
editor={editor}
scrollContainerRef={isBare ? BODY_PORTAL : containerRef}
/>
)}
{editor && <LinkHoverCard editor={editor} />}
{uploadImage && (
<input
ref={imageInputRef}
type='file'
accept='image/*'
multiple
hidden
onChange={(event) => {
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)
}}
/>
)}
<EditorContent
editor={editor}
className='flex flex-1 flex-col selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
className={cn(
'flex flex-1 flex-col',
isBare
? proseClassName
: 'selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
)}
/>
</div>
)
@@ -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<HTMLTextAreaElement>) => {
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 (
<textarea
ref={textareaRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onPaste={handlePaste}
placeholder={placeholder}
readOnly={isStreaming || lockedView}
tabIndex={lockedView ? -1 : undefined}
className={cn(
'w-full resize-none border-none bg-transparent p-0 text-current caret-current outline-none focus-visible:outline-none',
'scrollbar-none placeholder:text-current placeholder:opacity-55',
lockedView && 'select-none opacity-50',
autoGrow && 'overflow-hidden',
proseClassName
)}
style={{ minHeight: boxMinHeight, maxHeight }}
/>
)
}
return (
<ChipTextarea
ref={textareaRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onPaste={(event) => {
const text = event.clipboardData.getData('text/plain')
if (text && onPasteText?.(text)) event.preventDefault()
}}
onPaste={handlePaste}
placeholder={placeholder}
error={error}
viewOnly={lockedView}
readOnly={isStreaming}
tabIndex={lockedView ? -1 : undefined}
className={cn(lockedView && 'select-none opacity-50', autoGrow && 'overflow-hidden')}
style={{ minHeight, maxHeight }}
style={{ minHeight: boxMinHeight, maxHeight }}
/>
)
}
@@ -283,6 +511,12 @@ function RawMarkdownField({
* Mirrors the file editor's safety gate (decided once from the initial value):
* round-trip-safe content opens in the WYSIWYG editor, while lossy markdown (raw HTML, footnotes,
* comments) falls back to raw-text editing so an edit can't silently drop those constructs.
*
* Blast radius, since this sits under the file editor's directory but is NOT the file editor:
* `RichMarkdownEditor` (the workspace file editor) does not render this component at all. Changing
* it reaches the canvas Note, the skill modal, skill fields, and the deploy version description —
* and nothing else. The shared base both editors DO have in common is everything else in this
* folder: the extension set, the markdown parse/serialize, the menus, and the stylesheet.
*/
export function RichMarkdownField(props: RichMarkdownFieldProps) {
const [isSafe] = useState(() => isRoundTripSafe(props.value))
@@ -673,6 +673,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
})
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
if (span.tries !== undefined) metaEntries.push({ label: 'Tries', value: String(span.tries) })
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
if (span.model) metaEntries.push({ label: 'Model', value: span.model })
if (span.finishReason) metaEntries.push({ label: 'Finish reason', value: span.finishReason })
@@ -1,7 +1,7 @@
import type React from 'react'
import { perceivedBrightness } from '@sim/utils/color'
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
import { formatCreditCost } from '@/lib/billing/credits/conversion'
import { perceivedBrightness } from '@/lib/colors'
import { hasUnhandledError } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
@@ -1,6 +1,25 @@
import { memo, useCallback } from 'react'
import { Button, cn, Duplicate, PlayOutline, Tooltip, Trash, toast } from '@sim/emcn'
import { ArrowLeftRight, ArrowUpDown, Ban, Circle, Lock, LogOut, Unlock } from '@sim/emcn/icons'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
Duplicate,
PlayOutline,
Tooltip,
Trash,
toast,
} from '@sim/emcn'
import { Ban, Circle, Lock, LogOut, Palette, Square, Unlock } from '@sim/emcn/icons'
import {
DEFAULT_NOTE_COLOR,
isNoteColor,
NOTE_COLOR_OPTIONS,
type NoteColor,
} from '@sim/workflow-renderer'
import { useShallow } from 'zustand/react/shallow'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -10,21 +29,113 @@ import {
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useIsCurrentWorkflowExecuting, useLastExecutionSnapshot } from '@/stores/execution'
import { useLastExecutionSnapshot } from '@/stores/execution'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
const ACTION_BUTTON_STYLES = [
'size-[23px] rounded-lg p-0',
'border border-[var(--border)] bg-[var(--surface-5)]',
'text-[var(--text-secondary)]',
'hover-hover:border-transparent hover-hover:bg-[var(--brand-secondary)] hover-hover:!text-[var(--text-inverse)]',
'dark:border-transparent dark:bg-[var(--surface-7)] dark:hover-hover:bg-[var(--brand-secondary)]',
'size-[24px] rounded-md p-0',
'border-none bg-transparent text-[var(--text-icon)]',
'hover-hover:bg-[var(--surface-5)] hover-hover:!text-[var(--text-primary)]',
'dark:hover-hover:bg-[var(--surface-4)]',
'transition-[background-color,color,opacity,transform] duration-150 active:scale-[0.96]',
].join(' ')
const ICON_SIZE = 'size-[11px]'
/**
* The running hatch: the squares' own rhythm, sheared left, scrolling.
*
* Indeterminate, not a progress fill — it spans the whole run and the pattern
* marches (`animate-running-hatch-scroll` shifts the background by exactly one
* 26px period, which is what makes the loop seamless). A block gives no signal
* of how far along it is, so a bar that filled was inventing one.
*
* Same geometry the slots used before — a 24px mark with the row's 2px gap after
* it — so the marks land where the squares did. The scroll period below must
* stay equal to that 26px pitch or the loop visibly jumps.
*
* Painted ONCE across the row, not per slot. Each button would start its own
* gradient at its own origin, so the phase reset at every slot — and the end
* slots are 40px against the others' 24px, so the resets were not even uniform.
* One element spanning the row has one gradient and therefore one phase, which
* is what lets the 24/2 rhythm hold all the way along.
*
* Stops are measured along the 75° axis rather than horizontally, so both carry
* a `sin(75°)` factor: 24px of mark is 23.18px of stop, and the 26px pitch is
* 25.11px of period. Writing 24/26 directly would render ~3.5% wide and drift
* out of the squares' rhythm across the row.
*
* `--surface-2` is the same fill the slots used; only where it is painted moved.
*/
const RUNNING_FILL =
'bg-[repeating-linear-gradient(75deg,var(--surface-2)_0_23.18px,transparent_23.18px_25.11px)]'
/** Left edge of the fill: clears the run/stop button, which stays live mid-run. */
const RUNNING_FILL_INSET_SWELL = 'left-[42px]'
const RUNNING_FILL_INSET_PLAIN = 'left-[26px]'
/**
* Trims the fill to the swell's tapered end.
*
* The row is a rectangle but the swell is not: its last slot cuts a diagonal
* (`M16.25 0 … L36.59 19.9 …`) so the shape narrows toward the top. A rectangular
* overlay therefore paints past the gray edge at the top while still sitting
* inside it at the bottom — the fill visibly ran off the block. The per-slot
* version never did, because each button's own clip contained it.
*
* Same taper, read off that path: the edge sits 16.67px in from the row's right
* at the overlay's top (y=4) and 3.33px at its bottom (y=20), a slope of 20/24.
* Changing the end silhouette means changing these two numbers with it.
*/
const RUNNING_FILL_END_TAPER =
'[clip-path:polygon(0_0,calc(100%_-_16.67px)_0,calc(100%_-_3.33px)_100%,0_100%)]'
const ICON_SIZE = 'size-[14px]'
type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete' | 'color'
/**
* Spinner that swaps to a stop glyph on hover.
*
* Carries the run's status announcement: the glyph itself is decorative, but
* the block going from idle to running is only otherwise conveyed by the
* button's `aria-label` flip, which a screen reader reads on focus rather than
* when the state changes.
*/
function RunningActionIcon() {
return (
<span
className='relative grid size-[14px] translate-x-[8px] translate-y-px place-items-center'
role='status'
>
<span className='sr-only'>Block running</span>
<span
aria-hidden='true'
className='col-start-1 row-start-1 opacity-100 transition-opacity duration-100 group-hover/run:opacity-0 group-focus-visible/run:opacity-0 motion-safe:animate-spin motion-reduce:transition-none'
>
<svg className='size-[14px]' viewBox='0 0 24 24' fill='none'>
<circle cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='2' opacity='0.25' />
<circle
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeDasharray='18 45'
/>
</svg>
</span>
<span
aria-hidden='true'
className='col-start-1 row-start-1 opacity-0 transition-opacity duration-100 group-hover/run:opacity-100 group-focus-visible/run:opacity-100 motion-reduce:transition-none'
>
<Square className='size-[11px] fill-current' strokeWidth={0} />
</span>
</span>
)
}
/**
* Props for the ActionBar component
@@ -36,6 +147,16 @@ interface ActionBarProps {
blockType: string
/** Whether the action bar is disabled */
disabled?: boolean
/** Places the actions inside the workflow card's border swell. */
variant?: 'floating' | 'swell'
/** Whether this block is currently executing. */
isRunning?: boolean
/** Whether any block in the current workflow is executing. */
isWorkflowRunning?: boolean
noteColor?: NoteColor
onNoteColorChange?: (color: NoteColor) => void
/** Keeps the note and its swell selected while the portalled color menu is open. */
onNoteColorMenuOpen?: () => void
}
/**
@@ -45,17 +166,25 @@ interface ActionBarProps {
* @component
*/
export const ActionBar = memo(
function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) {
function ActionBar({
blockId,
blockType,
disabled = false,
variant = 'floating',
isRunning = false,
isWorkflowRunning = false,
noteColor = DEFAULT_NOTE_COLOR,
onNoteColorChange,
onNoteColorMenuOpen,
}: ActionBarProps) {
const {
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
const { handleRunFromBlock } = useWorkflowExecution()
const { handleCancelExecution, handleRunFromBlock } = useWorkflowExecution()
const handleDuplicateBlock = useCallback(() => {
const { copyBlocks, preparePasteData } = useWorkflowRegistry.getState()
const existingBlocks = useWorkflowStore.getState().blocks
@@ -81,33 +210,24 @@ export const ActionBar = memo(
)
}, [blockId, collaborativeBatchAddBlocks, setPendingSelection])
const {
isEnabled,
horizontalHandles,
parentId,
parentType,
isLocked,
isParentLocked,
isParentDisabled,
} = useWorkflowStore(
useShallow((state) => {
const block = state.blocks[blockId]
const parentId = block?.data?.parentId
const parentBlock = parentId ? state.blocks[parentId] : undefined
return {
isEnabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType: parentBlock?.type,
isLocked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
}
})
)
const { isEnabled, parentId, parentType, isLocked, isParentLocked, isParentDisabled } =
useWorkflowStore(
useShallow((state) => {
const block = state.blocks[blockId]
const parentId = block?.data?.parentId
const parentBlock = parentId ? state.blocks[parentId] : undefined
return {
isEnabled: block?.enabled ?? true,
parentId,
parentType: parentBlock?.type,
isLocked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
}
})
)
const { activeWorkflowId } = useWorkflowRegistry()
const isExecuting = useIsCurrentWorkflowExecuting()
const snapshot = useLastExecutionSnapshot(activeWorkflowId)
const userPermissions = useUserPermissionsContext()
const edges = useWorkflowStore((state) => state.edges)
@@ -115,12 +235,130 @@ export const ActionBar = memo(
const isStartBlock = isInputDefinitionTrigger(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
const { dependenciesSatisfied } = getRunFromBlockDependencyState(blockId, edges, snapshot)
const canRunFromBlock =
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isWorkflowRunning
/*
* One rule per action, shared by the button's `disabled` and its handler —
* previously the handler cancelled unconditionally while `disabled` only
* applied to Run, so a locked or read-only workflow still offered a Stop
* the server answers with 403. Cancelling is gated on `disabled` alone:
* that is `!canEditWorkflow`, which mirrors the route's `write` check,
* while a per-block lock has no bearing on stopping the whole run.
*/
const canStopWorkflow = isWorkflowRunning && !disabled
const canRunBlock =
!isWorkflowRunning && canRunFromBlock && !disabled && !isLocked && !isParentLocked
const isSwell = variant === 'swell'
const firstActionId: ActionId = isNoteBlock
? 'color'
: !isInsideSubflow || isWorkflowRunning
? 'run'
: 'enabled'
/* The slots the hatch runs across — they blank their icons so it reads
uninterrupted. Not memoised: only `indexOf` is read, never the array's
identity, so a memo here would allocate a deps array to save an
allocation of the same size. */
const runningSweepActionIds: ActionId[] = [
...(!isNoteBlock ? (['enabled'] as const) : []),
...(userPermissions.canAdmin ? (['lock'] as const) : []),
...(!isStartBlock && !isResponseBlock ? (['duplicate'] as const) : []),
...(!isStartBlock && isInsideSubflow ? (['remove'] as const) : []),
'delete',
]
/*
* Gated on the workflow as well as the block. `isRunning` comes from
* `activeBlockIds`, which carries no `isExecuting` guard of its own — so a
* run that exited without clearing it would otherwise leave this card's
* icons at `opacity-0` with a 160ms interval ticking forever. Every run
* path sets both, so this changes nothing that is reachable today.
*/
const isSweeping = isWorkflowRunning && isRunning
/*
* Icon treatment follows the swell's own fill, published by the card view
* as `data-node-selected`. Keying off React Flow's raw `selected` would
* diverge: an executing block keeps the success ring, so the swell stays
* gray (or retracts) while `selected` is still true.
*/
const actionButtonStyles = cn(
ACTION_BUTTON_STYLES,
isSwell && [
'group-data-[node-selected]:text-[var(--surface-2)]',
'hover-hover:group-data-[node-selected]:bg-[var(--surface-2)]',
'hover-hover:group-data-[node-selected]:!text-[var(--text-primary)]',
]
)
/*
* End-button silhouettes: a straight diagonal edge running parallel to
* the gray swell's taper above it (slope 20/24 ≈ 40° from vertical — the
* falloff curve's central gradient), blended into the top edge with a
* generous r8 arc and meeting the bottom edge with a pointier r2.5 arc;
* the outer corners keep a modest r4. The right (delete) shape is the
* exact mirror of the left (first action) shape. Width is 40px — outer
* diagonal pushed out so the glyph has room before the cut; inner side
* stays tight against neighboring actions. The row is right-[24px] to
* match the swell anchor inset (right-aligned on the card). Glyphs shift
* away from the outer cut (+6 / -6). Play gets an extra +2px because the
* triangle’s optical center sits left of its viewBox center; the Note
* palette uses the same inset so its first-action padding matches.
*/
const getActionButtonStyles = (actionId: ActionId) => {
const runningSweepIndex = runningSweepActionIds.indexOf(actionId)
const isRunningSweepSlot = isSweeping && runningSweepIndex >= 0
return cn(
actionButtonStyles,
!isWorkflowRunning &&
((actionId === 'enabled' && !isEnabled) || (actionId === 'lock' && isLocked)) && [
'bg-[var(--text-secondary)] text-[var(--text-inverse)]',
],
actionId === 'run' &&
isRunning && [
'!bg-[var(--text-secondary)] !text-[var(--text-inverse)]',
'hover-hover:!bg-[var(--white)] hover-hover:!text-[var(--surface-inverted)]',
'dark:hover-hover:!bg-[var(--surface-4)] dark:hover-hover:!text-[var(--text-primary)]',
'focus-visible:!bg-[var(--white)] focus-visible:!text-[var(--surface-inverted)]',
'dark:focus-visible:!bg-[var(--surface-4)] dark:focus-visible:!text-[var(--text-primary)]',
],
isSwell &&
actionId === firstActionId &&
"!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&>svg]:translate-y-px",
isSwell &&
actionId === firstActionId &&
(actionId === 'run' || actionId === 'color'
? '[&>svg]:translate-x-[8px]'
: '[&>svg]:translate-x-[6px]'),
isSwell &&
actionId === 'delete' &&
"!w-[40px] [clip-path:path('M16.25_0A8_8_0_0_1_22.4_2.88L36.59_19.9A2.5_2.5_0_0_1_34.66_24L4_24A4_4_0_0_1_0_20L0_4A4_4_0_0_1_4_0Z')] [&_svg]:-translate-x-[6px] [&_svg]:translate-y-px",
/*
* A bystander card's actions dim mid-run because they are not available
* — but `run` is Stop while the workflow runs, and `canStopWorkflow` is
* true on every card, running or not. Dimming it styled a live control
* as a dead one: it recovered full opacity only once the pointer was
* already on it, so it read as unclickable right up until you clicked.
* It keeps the ordinary resting and hover chrome instead.
*/
isWorkflowRunning &&
!isRunning &&
actionId !== 'run' && [
'!bg-transparent !opacity-25',
'hover-hover:!bg-transparent dark:hover-hover:!bg-transparent',
],
/* The hatch is painted across the row, not per slot — a slot only clears
itself out of its way. */
isRunningSweepSlot && [
'!opacity-100 [&_svg]:!opacity-0',
'!bg-transparent hover-hover:!bg-transparent',
],
/* `!` is required: these buttons are also `disabled` when locked, and
the emcn Button base carries `disabled:opacity-70`, which outranks a
plain `opacity-35` on specificity. */
!isWorkflowRunning && actionId !== 'lock' && isLocked && '!opacity-35'
)
}
const handleRunFromBlockClick = useCallback(() => {
if (!activeWorkflowId || !canRunFromBlock) return
@@ -142,205 +380,326 @@ export const ActionBar = memo(
return (
<div
data-workflow-action-bar-swell={isSwell ? '' : undefined}
className={cn(
'-top-[46px] pointer-events-auto absolute right-0',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] p-[5px]',
'border border-[var(--border)] bg-[var(--surface-2)]',
'dark:border-transparent dark:bg-[var(--surface-4)]'
'absolute rounded-lg',
isSwell
? [
// Above RF handles (`z-30`) so icons stay clickable when a top edge crosses.
'-top-[28px] right-[24px] z-[40] h-[28px] w-fit overflow-hidden px-[0.2rem] py-0.5',
'pointer-events-auto',
]
: [
'-top-[40px] pointer-events-auto right-0 flex flex-row items-center gap-[2px] p-[3px]',
'border-[1.5px] border-[var(--border-1)] bg-[var(--surface-2)]',
'opacity-0 transition-opacity duration-[150ms] group-hover:opacity-100',
]
)}
>
{!isNoteBlock && !isInsideSubflow && (
<Tooltip.Root>
<div
className={cn(
'relative flex flex-row items-center gap-[2px]',
isSwell && [
'pointer-events-none h-full opacity-0 transition-opacity duration-[30ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]',
'group-data-[action-menu-ready]:pointer-events-auto group-data-[action-menu-ready]:opacity-100 group-data-[action-menu-ready]:duration-100',
]
)}
>
{/*
Painted behind the row rather than into each slot, so the hatch has a
single phase across the whole span. The run/stop button keeps an
opaque fill while running, which masks the hatch behind it; every
other slot is transparent mid-run, so it shows through.
*/}
{isSweeping && (
<span
aria-hidden='true'
className={cn(
'pointer-events-none absolute inset-y-[4px] right-0 overflow-hidden',
isSwell ? RUNNING_FILL_INSET_SWELL : RUNNING_FILL_INSET_PLAIN,
isSwell && RUNNING_FILL_END_TAPER
)}
>
<span
className={cn(
/* One period wider than the wrapper, so translating a full
period never exposes an edge at either end. */
'block h-full w-[calc(100%_+_26px)] will-change-transform',
'animate-running-hatch-scroll motion-reduce:animate-none',
RUNNING_FILL
)}
/>
</span>
)}
{!isNoteBlock && (!isInsideSubflow || isWorkflowRunning) && (
<Tooltip.Root preferAbove>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
aria-label={isWorkflowRunning ? 'Stop workflow' : 'Run block'}
onClick={(e) => {
e.stopPropagation()
if (canStopWorkflow) {
handleCancelExecution()
return
}
if (canRunBlock) handleRunFromBlockClick()
}}
className={cn(getActionButtonStyles('run'), isWorkflowRunning && 'group/run')}
disabled={!canStopWorkflow && !canRunBlock}
>
{isWorkflowRunning ? (
isRunning ? (
<RunningActionIcon />
) : (
<Square
className='size-[11px] fill-current'
aria-hidden='true'
strokeWidth={0}
/>
)
) : (
<PlayOutline className={ICON_SIZE} />
)}
</Button>
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{(() => {
if (isWorkflowRunning) return getTooltipMessage('Stop')
if (isLocked || isParentLocked) return 'Block is locked'
if (disabled) return getTooltipMessage('Run')
if (!dependenciesSatisfied) return 'Run previous blocks first'
return 'Run'
})()}
</Tooltip.Content>
</Tooltip.Root>
)}
{!isNoteBlock && (
<Tooltip.Root preferAbove>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
const cantEnable = !isEnabled && isParentDisabled
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={getActionButtonStyles('enabled')}
disabled={
isWorkflowRunning ||
disabled ||
isLocked ||
isParentLocked ||
(!isEnabled && isParentDisabled)
}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <Ban className={ICON_SIZE} />}
</Button>
</span>
</Tooltip.Trigger>
{!isWorkflowRunning && (
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: !isEnabled && isParentDisabled
? 'Parent container is disabled'
: getTooltipMessage(isEnabled ? 'Disable' : 'Enable')}
</Tooltip.Content>
)}
</Tooltip.Root>
)}
{isNoteBlock && (
<DropdownMenu
onOpenChange={(open) => {
if (open) onNoteColorMenuOpen?.()
}}
>
<Tooltip.Root preferAbove>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className={getActionButtonStyles('color')}
disabled={
isWorkflowRunning ||
disabled ||
isLocked ||
isParentLocked ||
!onNoteColorChange
}
aria-label='Note color'
onClick={(event) => event.stopPropagation()}
>
<Palette className={ICON_SIZE} />
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
{!isWorkflowRunning && <Tooltip.Content side='top'>Color</Tooltip.Content>}
</Tooltip.Root>
<DropdownMenuContent
align='center'
side='top'
sideOffset={8}
className='w-fit min-w-0 rounded-full p-1'
>
<DropdownMenuRadioGroup
value={noteColor}
className='flex flex-col gap-0.5'
onValueChange={(value) => {
if (isNoteColor(value)) onNoteColorChange?.(value)
}}
>
{NOTE_COLOR_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.id}
value={option.id}
aria-label={option.label}
className='size-[28px] cursor-pointer justify-center rounded-full p-0 [&>span:first-child]:hidden'
>
<span
className={cn(
'size-[16px] rounded-full border border-black/15',
option.swatchClassName,
option.id === noteColor &&
'ring-2 ring-[var(--text-primary)] ring-offset-1 ring-offset-[var(--bg)]'
)}
/>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{userPermissions.canAdmin && (
<Tooltip.Root preferAbove>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !(isLocked && isParentLocked)) {
collaborativeBatchToggleLocked([blockId])
}
}}
className={getActionButtonStyles('lock')}
disabled={isWorkflowRunning || disabled || (isLocked && isParentLocked)}
>
{isLocked ? <Lock className={ICON_SIZE} /> : <Unlock className={ICON_SIZE} />}
</Button>
</span>
</Tooltip.Trigger>
{!isWorkflowRunning && (
<Tooltip.Content side='top'>
{isLocked && isParentLocked
? 'Parent container is locked'
: isLocked
? 'Unlock'
: 'Lock'}
</Tooltip.Content>
)}
</Tooltip.Root>
)}
{!isStartBlock && !isResponseBlock && (
<Tooltip.Root preferAbove>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) {
handleDuplicateBlock()
}
}}
className={getActionButtonStyles('duplicate')}
disabled={isWorkflowRunning || disabled || isLocked || isParentLocked}
>
<Duplicate className={ICON_SIZE} />
</Button>
</span>
</Tooltip.Trigger>
{!isWorkflowRunning && (
<Tooltip.Content side='top'>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Duplicate')}
</Tooltip.Content>
)}
</Tooltip.Root>
)}
{!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip.Root preferAbove>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', {
detail: { blockIds: [blockId] },
})
)
}
}}
className={getActionButtonStyles('remove')}
disabled={
isWorkflowRunning ||
disabled ||
!userPermissions.canEdit ||
isLocked ||
isParentLocked
}
>
<LogOut className={ICON_SIZE} />
</Button>
</span>
</Tooltip.Trigger>
{!isWorkflowRunning && (
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Remove from Subflow')}
</Tooltip.Content>
)}
</Tooltip.Root>
)}
<Tooltip.Root preferAbove>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (canRunFromBlock && !disabled) {
handleRunFromBlockClick()
if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchRemoveBlocks([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !canRunFromBlock}
className={getActionButtonStyles('delete')}
disabled={isWorkflowRunning || disabled || isLocked || isParentLocked}
>
<PlayOutline className={ICON_SIZE} />
<Trash className={ICON_SIZE} />
</Button>
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{(() => {
if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Running...'
if (!dependenciesSatisfied) return 'Run previous blocks first'
return 'Run from block'
})()}
</Tooltip.Content>
{!isWorkflowRunning && (
<Tooltip.Content side='top'>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete')}
</Tooltip.Content>
)}
</Tooltip.Root>
)}
{!isNoteBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
// Can't enable if parent is disabled (must enable parent first)
const cantEnable = !isEnabled && isParentDisabled
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <Ban className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: !isEnabled && isParentDisabled
? 'Parent container is disabled'
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
{userPermissions.canAdmin && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
// Can't unlock a block if its parent container is locked
if (!disabled && !(isLocked && isParentLocked)) {
collaborativeBatchToggleLocked([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || (isLocked && isParentLocked)}
>
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked && isParentLocked
? 'Parent container is locked'
: isLocked
? 'Unlock Block'
: 'Lock Block'}
</Tooltip.Content>
</Tooltip.Root>
)}
{!isStartBlock && !isResponseBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) {
handleDuplicateBlock()
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked}
>
<Duplicate className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Duplicate Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
{!isNoteBlock && !isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked}
>
{horizontalHandles ? (
<ArrowLeftRight className={ICON_SIZE} />
) : (
<ArrowUpDown className={ICON_SIZE} />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
)}
{!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
)
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
>
<LogOut className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Remove from Subflow')}
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchRemoveBlocks([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked}
>
<Trash className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
)
},
@@ -356,7 +715,13 @@ export const ActionBar = memo(
return (
prevProps.blockId === nextProps.blockId &&
prevProps.blockType === nextProps.blockType &&
prevProps.disabled === nextProps.disabled
prevProps.disabled === nextProps.disabled &&
prevProps.variant === nextProps.variant &&
prevProps.isRunning === nextProps.isRunning &&
prevProps.isWorkflowRunning === nextProps.isWorkflowRunning &&
prevProps.noteColor === nextProps.noteColor &&
prevProps.onNoteColorChange === nextProps.onNoteColorChange &&
prevProps.onNoteColorMenuOpen === nextProps.onNoteColorMenuOpen
)
}
)
@@ -11,7 +11,6 @@ export interface BlockInfo {
id: string
type: string
enabled: boolean
horizontalHandles: boolean
parentId?: string
parentType?: string
locked?: boolean
@@ -34,10 +33,11 @@ export interface BlockMenuProps {
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
/** Prompts for an image and appends it to the note. Note blocks only. */
onAddImage: () => void
onRunFromBlock?: () => void
onRunUntilBlock?: () => void
hasClipboard?: boolean
@@ -74,10 +74,10 @@ export function BlockMenu({
onDuplicate,
onDelete,
onToggleEnabled,
onToggleHandles,
onRemoveFromSubflow,
onOpenEditor,
onRename,
onAddImage,
onRunFromBlock,
onRunUntilBlock,
hasClipboard = false,
@@ -130,13 +130,7 @@ export function BlockMenu({
}
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()} size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
@@ -207,17 +201,6 @@ export function BlockMenu({
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
</PopoverItem>
)}
{!allNoteBlocks && !isSubflow && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onToggleHandles()
onClose()
}}
>
Flip Handles
</PopoverItem>
)}
{canRemoveFromSubflow && (
<PopoverItem
disabled={disableEdit}
@@ -256,7 +239,20 @@ export function BlockMenu({
Rename
</PopoverItem>
)}
{isSingleBlock && (
{isSingleBlock && allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onAddImage()
onClose()
}}
>
Add Image
</PopoverItem>
)}
{/* Not for a note: the panel editor renders nothing for one and clears
it again, so the item would open an empty editor. */}
{isSingleBlock && !allNoteBlocks && (
<PopoverItem
onClick={() => {
onOpenEditor()
@@ -70,13 +70,7 @@ export function CanvasMenu({
hasBlocks = false,
}: CanvasMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()} size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
@@ -947,7 +947,7 @@ export function Chat() {
<div className='flex flex-shrink-0 items-center gap-2'>
{/* More menu with actions */}
<Popover variant='default' size='sm' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<Popover size='sm' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -0,0 +1,502 @@
'use client'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { Button, cn, X } from '@sim/emcn'
import { WorkflowBlockBorder, type WorkflowBorderPort } from '@sim/workflow-renderer'
import { Command } from 'cmdk'
import { Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { captureEvent } from '@/lib/posthog/client'
import { MemoizedCommandItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items'
import {
BlocksGroup,
ToolsGroup,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups'
import {
filterAndCap,
GROUP_HEADING_CLASSNAME,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import {
CMDK_ITEM_GAP_CLASS,
CMDK_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { useSearchModalStore } from '@/stores/modals/search/store'
import type {
PendingConnect,
SearchBlockItem,
SearchToolOperationItem,
} from '@/stores/modals/search/types'
export const CONNECTION_BLOCK_SELECTOR_NODE_ID = '__connection-block-selector__'
export const CONNECTION_BLOCK_SELECTOR_DIMENSIONS = {
width: 250,
height: 352,
} as const
const SELECTOR_ACTION_MENU_WIDTH = 140
const SELECTOR_ACTION_MENU_RIGHT_INSET = 24
const SELECTOR_ACTION_MENU_AMPLITUDE = 7
const RECENT_SELECTION_LIMIT = 3
const RECENT_SELECTION_STORAGE_PREFIX = 'sim:connection-block-selector:recent'
const POPULAR_BLOCK_TYPES = [
'agent',
'function',
'api',
'condition',
'knowledge',
'memory',
] as const
const SELECTOR_PORTS: WorkflowBorderPort[] = [
{
id: 'target',
side: 'left',
position: 'center',
plateau: 38,
color: 'var(--text-secondary)',
restAmplitude: 1,
hoverAmplitude: 1,
magnetizable: false,
},
{
id: 'action-menu',
side: 'top',
position: {
fromEnd: SELECTOR_ACTION_MENU_RIGHT_INSET + SELECTOR_ACTION_MENU_WIDTH / 2,
},
plateau: SELECTOR_ACTION_MENU_WIDTH,
restAmplitude: SELECTOR_ACTION_MENU_AMPLITUDE,
hoverAmplitude: SELECTOR_ACTION_MENU_AMPLITUDE,
magnetizable: false,
},
]
export interface ConnectionBlockSelectorData {
pendingConnect: PendingConnect
onClose: () => void
}
type RecentSelection =
| { kind: 'block'; id: string }
| { kind: 'tool'; id: string }
| { kind: 'tool_operation'; id: string }
type RecentPickerResult =
| { kind: 'block'; item: SearchBlockItem }
| { kind: 'tool'; item: SearchBlockItem }
| { kind: 'tool_operation'; item: SearchToolOperationItem }
function getBlockCommandValue(item: SearchBlockItem, kind: 'block' | 'tool'): string {
return `${item.name} ${kind}-${item.id}`
}
function getToolOperationCommandValue(item: SearchToolOperationItem): string {
return `${item.searchValue} operation-${item.id}`
}
function getPickerResultCommandValue(result: RecentPickerResult): string {
return result.kind === 'tool_operation'
? getToolOperationCommandValue(result.item)
: getBlockCommandValue(result.item, result.kind)
}
function getRecentCommandValue(result: RecentPickerResult): string {
return `recent ${getPickerResultCommandValue(result)}`
}
function isRecentSelection(value: unknown): value is RecentSelection {
if (
!value ||
typeof value !== 'object' ||
!('kind' in value) ||
!('id' in value) ||
typeof value.id !== 'string'
) {
return false
}
return value.kind === 'block' || value.kind === 'tool' || value.kind === 'tool_operation'
}
/**
* Transient on-canvas block picker shown after a connection is released on the
* pane. Its outer silhouette is the canonical workflow-card border, so the
* temporary edge reads as connected to the block that will replace it.
*/
export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockSelectorData>) {
const params = useParams()
const workspaceId = params.workspaceId as string
const currentWorkflowId = params.workflowId as string | undefined
const posthog = usePostHog()
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const [search, setSearch] = useState('')
const [selectedValue, setSelectedValue] = useState('')
const [recentSelections, setRecentSelections] = useState<RecentSelection[]>([])
const deferredSearch = useDeferredValue(search)
const isSearching = deferredSearch.trim().length > 0
const recentStorageKey = `${RECENT_SELECTION_STORAGE_PREFIX}:${workspaceId}`
const { blocks, tools, toolOperations } = useSearchModalStore((state) => state.data)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') data.onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [data])
useEffect(() => {
try {
const storedSelections = window.localStorage.getItem(recentStorageKey)
if (!storedSelections) {
setRecentSelections([])
return
}
const parsedSelections: unknown = JSON.parse(storedSelections)
setRecentSelections(
Array.isArray(parsedSelections)
? parsedSelections.filter(isRecentSelection).slice(0, RECENT_SELECTION_LIMIT)
: []
)
} catch {
setRecentSelections([])
}
}, [recentStorageKey])
const availableBlocks = useMemo(
() =>
blocks.filter(
(block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId
),
[blocks, currentWorkflowId]
)
const availableTools = useMemo(
() =>
tools.filter((tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId),
[currentWorkflowId, tools]
)
const searchCandidates = useMemo<RecentPickerResult[]>(
() => [
...availableBlocks.map((item): RecentPickerResult => ({ kind: 'block', item })),
...availableTools.map((item): RecentPickerResult => ({ kind: 'tool', item })),
...toolOperations.map((item): RecentPickerResult => ({ kind: 'tool_operation', item })),
],
[availableBlocks, availableTools, toolOperations]
)
const searchResults = useMemo(
() =>
deferredSearch.trim()
? filterAndCap(
searchCandidates,
(result) => result.item.name,
deferredSearch,
(result) => result.item.searchValue
)
: [],
[deferredSearch, searchCandidates]
)
const recentSelectionKeys = useMemo(
() => new Set(recentSelections.map((selection) => `${selection.kind}:${selection.id}`)),
[recentSelections]
)
const recentResults = useMemo(() => {
const blocksById = new Map(availableBlocks.map((block) => [block.id, block]))
const toolsById = new Map(availableTools.map((tool) => [tool.id, tool]))
const operationsById = new Map(toolOperations.map((operation) => [operation.id, operation]))
const results: RecentPickerResult[] = []
for (const selection of recentSelections) {
if (selection.kind === 'block') {
const item = blocksById.get(selection.id)
if (item) results.push({ kind: 'block', item })
} else if (selection.kind === 'tool') {
const item = toolsById.get(selection.id)
if (item) results.push({ kind: 'tool', item })
} else {
const item = operationsById.get(selection.id)
if (item) results.push({ kind: 'tool_operation', item })
}
}
return results.slice(0, RECENT_SELECTION_LIMIT)
}, [availableBlocks, availableTools, recentSelections, toolOperations])
const popularBlocks = useMemo(
() =>
POPULAR_BLOCK_TYPES.map((type) => availableBlocks.find((block) => block.type === type))
.filter((block): block is SearchBlockItem => Boolean(block))
.filter((block) => !recentSelectionKeys.has(`block:${block.id}`))
.slice(0, RECENT_SELECTION_LIMIT),
[availableBlocks, recentSelectionKeys]
)
const popularBlockIds = useMemo(
() => new Set(popularBlocks.map((block) => block.id)),
[popularBlocks]
)
const browseBlocks = useMemo(
() =>
availableBlocks.filter(
(block) => !recentSelectionKeys.has(`block:${block.id}`) && !popularBlockIds.has(block.id)
),
[availableBlocks, popularBlockIds, recentSelectionKeys]
)
const browseTools = useMemo(
() => availableTools.filter((tool) => !recentSelectionKeys.has(`tool:${tool.id}`)),
[availableTools, recentSelectionKeys]
)
const dispatchSelection = useCallback(
(type: string, resultType: 'block' | 'tool' | 'tool_operation', presetOperation?: string) => {
window.dispatchEvent(
new CustomEvent('add-block-from-toolbar', {
detail: {
type,
presetOperation,
pendingConnect: data.pendingConnect,
},
})
)
captureEvent(posthog, 'search_result_selected', {
result_type: resultType,
query_length: deferredSearch.length,
workspace_id: workspaceId,
})
data.onClose()
},
[data, deferredSearch.length, posthog, workspaceId]
)
const rememberSelection = useCallback(
(selection: RecentSelection) => {
const nextSelections = [
selection,
...recentSelections.filter(
(recent) => recent.kind !== selection.kind || recent.id !== selection.id
),
].slice(0, RECENT_SELECTION_LIMIT)
setRecentSelections(nextSelections)
try {
window.localStorage.setItem(recentStorageKey, JSON.stringify(nextSelections))
} catch {
return
}
},
[recentSelections, recentStorageKey]
)
const handleBlockSelect = useCallback(
(block: SearchBlockItem) => {
rememberSelection({ kind: 'block', id: block.id })
dispatchSelection(block.type, 'block')
},
[dispatchSelection, rememberSelection]
)
const handleToolSelect = useCallback(
(tool: SearchBlockItem) => {
rememberSelection({ kind: 'tool', id: tool.id })
dispatchSelection(tool.type, 'tool')
},
[dispatchSelection, rememberSelection]
)
const handleToolOperationSelect = (operation: SearchToolOperationItem) => {
rememberSelection({ kind: 'tool_operation', id: operation.id })
dispatchSelection(operation.blockType, 'tool_operation', operation.operationId)
}
const resetResultsViewport = useCallback(() => {
const list = listRef.current
if (!list) return
list.scrollTop = 0
}, [])
let firstResultValue = ''
const firstSearchResult = searchResults[0]
const firstRecent = recentResults[0]
const firstPopular = popularBlocks[0]
const firstBrowseBlock = browseBlocks[0]
const firstBrowseTool = browseTools[0]
if (isSearching) {
if (firstSearchResult) firstResultValue = getPickerResultCommandValue(firstSearchResult)
} else if (firstRecent) {
firstResultValue = getRecentCommandValue(firstRecent)
} else if (firstPopular) {
firstResultValue = getBlockCommandValue(firstPopular, 'block')
} else if (firstBrowseBlock) {
firstResultValue = getBlockCommandValue(firstBrowseBlock, 'block')
} else if (firstBrowseTool) {
firstResultValue = getBlockCommandValue(firstBrowseTool, 'tool')
}
const resolvedSelectedValue = selectedValue || (search === deferredSearch ? firstResultValue : '')
const handleSearchChange = (value: string) => {
setSearch(value)
setSelectedValue('')
requestAnimationFrame(resetResultsViewport)
}
useEffect(() => {
const frame = requestAnimationFrame(resetResultsViewport)
return () => cancelAnimationFrame(frame)
}, [deferredSearch, resetResultsViewport])
return (
<div
role='dialog'
aria-label='Add a connected block'
className='workflow-drag-handle relative h-[352px] w-[250px] cursor-grab select-none rounded-2xl [&:active]:cursor-grabbing'
>
<WorkflowBlockBorder
nodeId={id}
ports={SELECTOR_PORTS}
cursorSwellEnabled={false}
hasRing
ringStyles='ring-[1.5px] ring-[var(--text-secondary)]'
height={CONNECTION_BLOCK_SELECTOR_DIMENSIONS.height}
/>
<div
data-workflow-action-bar-swell=''
className='-top-[28px] pointer-events-auto absolute right-[24px] z-30 flex h-[28px] w-[140px] items-center justify-between gap-1.5 overflow-hidden py-0.5 pr-[0.2rem] pl-5'
>
<span className='min-w-max shrink-0 whitespace-nowrap font-medium text-[var(--surface-2)] text-sm leading-5'>
Add block
</span>
<Button
variant='ghost'
onClick={data.onClose}
aria-label='Close block selector'
title='Close'
className="nodrag nopan !h-[24px] !w-[40px] !bg-[color-mix(in_srgb,var(--surface-2)_18%,transparent)] !text-[color-mix(in_srgb,var(--text-inverse)_72%,transparent)] hover-hover:!bg-[var(--surface-2)] hover-hover:!text-[var(--text-primary)] [&_svg]:-translate-x-[6px] shrink-0 rounded-md border-none p-0 transition-[background-color,color,opacity,transform] duration-150 [clip-path:path('M16.25_0A8_8_0_0_1_22.4_2.88L36.59_19.9A2.5_2.5_0_0_1_34.66_24L4_24A4_4_0_0_1_0_20L0_4A4_4_0_0_1_4_0Z')] active:scale-[0.96] [&_svg]:translate-y-px"
>
<X className='size-[14px]' />
</Button>
</div>
<Handle
type='target'
position={Position.Left}
id='target'
className='!left-0 !top-1/2 !z-30 !h-[38px] !w-[14px] !-translate-y-1/2 !cursor-default !rounded-none !border-none !bg-transparent !opacity-0'
isConnectableStart={false}
isConnectableEnd={false}
/>
<Command
label='Add a connected block'
shouldFilter={false}
value={resolvedSelectedValue}
onValueChange={setSelectedValue}
className='relative z-20 flex h-full flex-col overflow-hidden rounded-2xl [clip-path:inset(0_round_16px)]'
>
<div className='relative min-h-0 flex-1'>
<Command.List
ref={listRef}
className={cn(
"nodrag nopan nowheel allow-scroll scrollbar-none [&_[cmdk-item][aria-selected='true']]:!border-transparent [&_[cmdk-item][aria-selected='true']]:!bg-[var(--surface-hover)] [&_[cmdk-item]_svg]:!scale-100 [&_[cmdk-item]_svg]:!transition-none h-full overflow-y-auto overflow-x-hidden px-1.5 pt-12 pb-1.5 [-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [clip-path:inset(3px_round_13px)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [&_[cmdk-group-items]]:flex [&_[cmdk-group-items]]:flex-col",
CMDK_ITEM_GAP_CLASS,
CMDK_SECTION_GAP_CLASS
)}
>
<Command.Empty className='flex items-center justify-center px-4 py-8 text-center text-[var(--text-subtle)] text-sm'>
No blocks found.
</Command.Empty>
{isSearching ? (
searchResults.length > 0 && (
<Command.Group className={GROUP_HEADING_CLASSNAME}>
{searchResults.map((result) => (
<MemoizedCommandItem
key={`search-${result.kind}-${result.item.id}`}
value={getPickerResultCommandValue(result)}
onSelect={() => {
if (result.kind === 'block') handleBlockSelect(result.item)
else if (result.kind === 'tool') handleToolSelect(result.item)
else handleToolOperationSelect(result.item)
}}
icon={result.item.icon}
bgColor={result.item.bgColor}
showColoredIcon
workflowType={result.kind === 'block' ? result.item.type : undefined}
label={result.item.name}
/>
))}
</Command.Group>
)
) : (
<>
{recentResults.length > 0 && (
<Command.Group heading='Recently used' className={GROUP_HEADING_CLASSNAME}>
{recentResults.map((result) => {
if (result.kind === 'tool_operation') {
return (
<MemoizedCommandItem
key={`recent-operation-${result.item.id}`}
value={getRecentCommandValue(result)}
onSelect={() => handleToolOperationSelect(result.item)}
icon={result.item.icon}
bgColor={result.item.bgColor}
showColoredIcon
label={result.item.name}
/>
)
}
return (
<MemoizedCommandItem
key={`recent-${result.kind}-${result.item.id}`}
value={getRecentCommandValue(result)}
onSelect={() =>
result.kind === 'block'
? handleBlockSelect(result.item)
: handleToolSelect(result.item)
}
icon={result.item.icon}
bgColor={result.item.bgColor}
showColoredIcon
workflowType={result.kind === 'block' ? result.item.type : undefined}
label={result.item.name}
/>
)
})}
</Command.Group>
)}
<BlocksGroup items={popularBlocks} onSelect={handleBlockSelect} heading='Popular' />
<BlocksGroup
items={browseBlocks}
onSelect={handleBlockSelect}
heading='All blocks'
/>
<ToolsGroup items={browseTools} onSelect={handleToolSelect} />
</>
)}
</Command.List>
<div className='nodrag nopan absolute inset-x-[3px] top-[3px] z-20 flex h-12 cursor-text items-center gap-2 rounded-t-[13px] bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)] px-2.5 pb-2'>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-muted)]' />
<Command.Input
ref={inputRef}
autoFocus
aria-label='Search blocks'
value={search}
onValueChange={handleSearchChange}
placeholder='Search blocks...'
className='h-8 min-w-0 flex-1 cursor-text bg-transparent text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] focus:outline-none'
/>
</div>
</div>
</Command>
</div>
)
}
@@ -1,23 +1,55 @@
import { memo, useCallback, useMemo } from 'react'
import { BLOCK_DIMENSIONS, NoteBlockView } from '@sim/workflow-renderer'
import type { NodeProps } from 'reactflow'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BLOCK_DIMENSIONS,
DEFAULT_NOTE_COLOR,
estimateNoteBlockHeight,
getNoteStringValue,
isNoteColor,
NoteBlockView,
type NoteColor,
type NoteContentEditorProps,
} from '@sim/workflow-renderer'
import dynamic from 'next/dynamic'
import { type NodeProps, useReactFlow } from 'reactflow'
import { appendNoteImageMarkdown } from '@/lib/workflows/notes/add-image'
import {
NOTE_ADD_IMAGE_EVENT,
NOTE_RENAME_EVENT,
type NoteBlockRequestDetail,
} from '@/lib/workflows/notes/canvas-requests'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useNoteImageUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/use-note-image-upload'
import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { isBlockProtected } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useIsCurrentWorkflowExecuting } from '@/stores/execution'
import { usePanelEditorStore } from '@/stores/panel'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowBlockProps } from '../workflow-block/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface NoteBlockNodeData extends WorkflowBlockProps {}
/** Extracts the string content from a raw subblock value (string or `{ value }`). */
function extractFieldValue(rawValue: unknown): string | undefined {
if (typeof rawValue === 'string') return rawValue
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
const candidate = (rawValue as { value?: unknown }).value
return typeof candidate === 'string' ? candidate : undefined
}
return undefined
/**
* Lazy for the same reason every other consumer of the markdown field is: it
* carries TipTap and the full extension set, and a Note only mounts it once the
* user clicks into the body. A static import would put all of that in the
* canvas's initial chunk, which every workflow pays for.
*/
const NoteMarkdownEditor = dynamic(
() =>
import(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-markdown-editor'
).then((m) => m.NoteMarkdownEditor),
{ ssr: false, loading: () => <div className='min-h-full w-full' /> }
)
const NOTE_EXPAND_FOCUS_DURATION_MS = 300
function renderNoteContentEditor(props: NoteContentEditorProps) {
return <NoteMarkdownEditor {...props} />
}
/**
@@ -34,12 +66,15 @@ export const NoteBlock = memo(function NoteBlock({
selected,
}: NodeProps<NoteBlockNodeData>) {
const { type, name } = data
const focusFrameRef = useRef<number | null>(null)
const { getNode, getViewport, setCenter } = useReactFlow()
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
const { activeWorkflowId, isEnabled, hasRing, ringStyles } = useBlockVisual({
blockId: id,
data,
isSelected: selected,
})
const { collaborativeSetSubblockValue, collaborativeUpdateBlockName } = useCollaborativeWorkflow()
const storedValues = useSubBlockStore(
useCallback(
(state) => {
@@ -52,17 +87,159 @@ export const NoteBlock = memo(function NoteBlock({
const content = useMemo(() => {
if (data.isPreview && data.subBlockValues) {
const extractedContent = extractFieldValue(data.subBlockValues.content)
const extractedContent = getNoteStringValue(data.subBlockValues.content)
return typeof extractedContent === 'string' ? extractedContent : ''
}
const storedContent = extractFieldValue(storedValues?.content)
const storedContent = getNoteStringValue(storedValues?.content)
return typeof storedContent === 'string' ? storedContent : ''
}, [data.isPreview, data.subBlockValues, storedValues])
const isEmpty = content.trim().length === 0
const rawColor = data.isPreview
? getNoteStringValue(data.subBlockValues?.color)
: getNoteStringValue(storedValues?.color)
const noteColor = isNoteColor(rawColor) ? rawColor : DEFAULT_NOTE_COLOR
const userPermissions = useUserPermissionsContext()
const isWorkflowRunning = useIsCurrentWorkflowExecuting()
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
const isProtected = useWorkflowStore(
useCallback((state) => isBlockProtected(id, state.blocks), [id])
)
const clearCurrentBlock = usePanelEditorStore((state) => state.clearCurrentBlock)
const canEditNote = canEditWorkflow && !data.isPreview && !isProtected
const uploadNoteImage = useNoteImageUpload()
const imageInputRef = useRef<HTMLInputElement>(null)
const [blockHeight, setBlockHeight] = useState(() => estimateNoteBlockHeight(content))
const [isExpanded, setIsExpanded] = useState(false)
const [externalContentWrites, setExternalContentWrites] = useState(0)
const [renameRequests, setRenameRequests] = useState(0)
const isFocused = selected
/* Collapsed during render rather than in an effect: losing edit rights while
expanded would otherwise paint one frame of an expanded-but-uneditable
note before the effect corrected it. Seeded with the live value so a note
that mounts uneditable does not reset anything it has not opened. */
const [prevCanEditNote, setPrevCanEditNote] = useState(canEditNote)
if (prevCanEditNote !== canEditNote) {
setPrevCanEditNote(canEditNote)
if (!canEditNote) setIsExpanded(false)
}
useEffect(
() => () => {
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current)
},
[]
)
const handleNameChange = (nextName: string) => {
if (!canEditNote) return false
return collaborativeUpdateBlockName(id, nextName).success
}
const handleContentChange = (nextContent: string) => {
if (!canEditNote) return
collaborativeSetSubblockValue(id, 'content', nextContent)
}
/**
* "Add image" from the canvas context menu. The picker, the upload and the
* write all live here because the note's content is the thing being changed —
* the editor may not even be mounted, and routing through it would mean
* opening one just to insert.
*/
useEffect(() => {
if (!canEditNote) return
const handleAddImage = (event: Event) => {
const detail = (event as CustomEvent<NoteBlockRequestDetail>).detail
if (detail?.blockId !== id) return
imageInputRef.current?.click()
}
window.addEventListener(NOTE_ADD_IMAGE_EVENT, handleAddImage)
return () => window.removeEventListener(NOTE_ADD_IMAGE_EVENT, handleAddImage)
}, [canEditNote, id])
/**
* Uploads then appends, one image at a time so a multi-file pick or drop lands
* in order. Each write goes to the store rather than to a mounted editor, so
* it counts as an external one: bumping `externalContentWrites` hands the
* document back to `content` first, or an open editor's next keystroke would
* serialize its own stale document over the appended image.
*
* Shared by both surfaces that reach the note from outside its editor: the
* context menu's "Add image" and a file dropped onto the card.
*/
const insertImages = async (files: File[]) => {
for (const file of files) {
const image = await uploadNoteImage(file)
if (!image) continue
/* Re-read per image: each append has to build on the previous one, and on
anything a collaborator wrote while the upload was in flight. */
const current = getNoteStringValue(useSubBlockStore.getState().getValue(id, 'content')) ?? ''
setExternalContentWrites((count) => count + 1)
collaborativeSetSubblockValue(id, 'content', appendNoteImageMarkdown(current, image))
}
}
const handleColorChange = useCallback(
(color: NoteColor) => {
if (!canEditNote) return
collaborativeSetSubblockValue(id, 'color', color)
},
[canEditNote, collaborativeSetSubblockValue, id]
)
const handleNoteSelect = useCallback(() => {
if (data.isPreview || data.isEmbedded) return
clearCurrentBlock()
}, [clearCurrentBlock, data.isEmbedded, data.isPreview])
const handleExpandedChange = useCallback(
(expanded: boolean) => {
if (expanded && !canEditNote) return
if (expanded) handleNoteSelect()
setIsExpanded(expanded)
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current)
if (!expanded) return
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
const node = getNode(id)
const position = node?.positionAbsolute ?? node?.position
if (!position) return
void setCenter(
position.x + BLOCK_DIMENSIONS.NOTE_WIDTH / 2,
position.y + blockHeight / 2,
{
zoom: getViewport().zoom,
duration: NOTE_EXPAND_FOCUS_DURATION_MS,
}
)
})
})
},
[blockHeight, canEditNote, getNode, getViewport, handleNoteSelect, id, setCenter]
)
/**
* "Rename" from the canvas context menu. The panel editor is not an option —
* it clears any note put in front of it and renders nothing — so the card's
* own title is the rename surface. That title is only reachable once the card
* is expanded, which is why this expands first and asks second.
*/
useEffect(() => {
if (!canEditNote) return
const handleRename = (event: Event) => {
const detail = (event as CustomEvent<NoteBlockRequestDetail>).detail
if (detail?.blockId !== id) return
handleExpandedChange(true)
setRenameRequests((count) => count + 1)
}
window.addEventListener(NOTE_RENAME_EVENT, handleRename)
return () => window.removeEventListener(NOTE_RENAME_EVENT, handleRename)
}, [canEditNote, handleExpandedChange, id])
/**
* Calculate deterministic dimensions based on content structure. Uses fixed
@@ -71,26 +248,63 @@ export const NoteBlock = memo(function NoteBlock({
useBlockDimensions({
blockId: id,
calculateDimensions: () => {
const contentHeight = isEmpty
? BLOCK_DIMENSIONS.NOTE_MIN_CONTENT_HEIGHT
: BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT
const calculatedHeight =
BLOCK_DIMENSIONS.HEADER_HEIGHT + BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + contentHeight
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
return {
width: BLOCK_DIMENSIONS.NOTE_WIDTH,
height: blockHeight,
}
},
dependencies: [isEmpty],
dependencies: [blockHeight],
})
return (
<NoteBlockView
name={name}
content={content}
isEnabled={isEnabled}
hasRing={hasRing}
ringStyles={ringStyles}
onSelect={handleClick}
actionBar={<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />}
/>
<>
<input
ref={imageInputRef}
type='file'
accept='image/*'
multiple
hidden
onChange={(event) => {
const input = event.currentTarget
const images = Array.from(input.files ?? []).filter((file) =>
file.type.startsWith('image/')
)
input.value = ''
if (images.length > 0) void insertImages(images)
}}
/>
<NoteBlockView
name={name}
content={content}
noteColor={noteColor}
isEnabled={isEnabled}
isFocused={isFocused}
isExpanded={isExpanded}
canEdit={canEditNote}
hasRing={hasRing}
ringStyles={ringStyles}
onSelect={handleNoteSelect}
onNameChange={handleNameChange}
onContentChange={handleContentChange}
externalContentWrites={externalContentWrites}
externalRenameRequests={renameRequests}
onHeightChange={setBlockHeight}
onExpandedChange={handleExpandedChange}
onImageFilesDrop={(files) => void insertImages(files)}
renderContentEditor={renderNoteContentEditor}
actionBar={
<ActionBar
blockId={id}
blockType={type}
disabled={!canEditWorkflow}
variant='swell'
isWorkflowRunning={isWorkflowRunning}
noteColor={noteColor}
onNoteColorChange={handleColorChange}
onNoteColorMenuOpen={handleNoteSelect}
/>
}
/>
</>
)
})
@@ -0,0 +1,192 @@
/**
* @vitest-environment jsdom
*/
import type { ComponentProps } from 'react'
import { act } from 'react'
import {
NOTE_MARKDOWN_FLOW,
NoteBlockView,
type NoteContentEditorProps,
} from '@sim/workflow-renderer'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { RichMarkdownField } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
import {
NOTE_EDITOR_PROSE_CLASS_NAME,
NoteMarkdownEditor,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-markdown-editor'
type FieldProps = ComponentProps<typeof RichMarkdownField>
/**
* The two halves of the seam, paired at the moment the field renders: the view
* assigns `view` as it builds the editor element, and the field records both as
* it renders inside that same pass. Reading them from separate variables after
* the fact could pair props from different renders.
*/
const { seam } = vi.hoisted(() => ({
seam: {
view: null as Record<string, unknown> | null,
captured: null as { view: Record<string, unknown>; field: Record<string, unknown> } | null,
},
}))
vi.mock(
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field',
() => ({
RichMarkdownField: (props: Record<string, unknown>) => {
if (seam.view) seam.captured = { view: seam.view, field: props }
return null
},
})
)
vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'ws-1' }) }))
vi.mock('@/hooks/queries/workspace-files', () => ({
useUploadWorkspaceFile: () => ({ mutateAsync: vi.fn() }),
}))
beforeAll(() => {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
onchange: null,
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
})
/**
* Every field of the view's editor contract, and where it has to land on the
* shared markdown field. Each case compares against what the view actually
* passed, and the suite fails if the view starts passing a field with no case
* here — the guard has to be a runtime one, because `type-check` excludes test
* files, so a compile-time completeness check in this file would never run.
*
* This is the shape of bug it catches: the skin destructured three of these
* fields and silently ignored the rest, which type-checks clean and shows up
* only as an invisible caret, a caret that ignores where you clicked, and image
* drops being swallowed.
*/
const FORWARDED: Record<
keyof NoteContentEditorProps,
(field: FieldProps, view: NoteContentEditorProps) => unknown
> = {
value: (field, view) => expect(field.value).toBe(view.value),
selectionClassName: (field, view) =>
expect(field.proseClassName).toContain(view.selectionClassName),
caretClassName: (field, view) => expect(field.editorClassName).toBe(view.caretClassName),
openedAt: (field, view) => expect(field.autoFocusAt).toBe(view.openedAt),
onChange: (field, view) => expect(field.onChange).toBe(view.onChange),
}
const NOTE_CONTENT = 'hello'
const CLICK_POINT = { clientX: 24, clientY: 48 }
let container: HTMLDivElement | null = null
let root: Root | null = null
afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
container = null
root = null
seam.view = null
seam.captured = null
})
/**
* Mounts a real Note card, clicks into its body, and returns both halves of the
* seam: what {@link NoteBlockView} handed the injected editor, and what that
* editor passed on to the shared field.
*/
function openNoteEditor(): { view: NoteContentEditorProps; field: FieldProps } {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() =>
root?.render(
<NoteBlockView
name='Note'
content={NOTE_CONTENT}
isEnabled
isFocused
isExpanded
canEdit
hasRing={false}
ringStyles=''
onSelect={() => undefined}
renderContentEditor={(props) => {
seam.view = props as unknown as Record<string, unknown>
return <NoteMarkdownEditor {...props} />
}}
/>
)
)
const editTrigger = container.querySelector('button[aria-label="Edit note content"]')
act(() => {
editTrigger?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, ...CLICK_POINT }))
editTrigger?.dispatchEvent(
new MouseEvent('click', { bubbles: true, detail: 1, ...CLICK_POINT })
)
})
if (!seam.captured) throw new Error('the note never rendered its markdown editor')
return {
view: seam.captured.view as unknown as NoteContentEditorProps,
field: seam.captured.field as FieldProps,
}
}
describe('NoteMarkdownEditor', () => {
it('covers every field the note view passes its editor', () => {
expect(Object.keys(openNoteEditor().view).sort()).toEqual(Object.keys(FORWARDED).sort())
})
it.each(Object.keys(FORWARDED) as (keyof NoteContentEditorProps)[])(
'forwards %s to the shared markdown field',
(name) => {
const { view, field } = openNoteEditor()
FORWARDED[name](field, view)
}
)
it('resolves the caret from the note colour, not the inherited ink', () => {
expect(openNoteEditor().field.editorClassName).toBe('caret-white')
})
it('lands the caret at the point that opened editing', () => {
expect(openNoteEditor().field.autoFocusAt).toEqual(CLICK_POINT)
})
it('enables image upload so paste and drop are not swallowed', () => {
expect(typeof openNoteEditor().field.uploadImage).toBe('function')
})
})
describe('note editor typography parity', () => {
it('mirrors every block-rhythm rule the read view paints', () => {
/*
* The read view renders through Streamdown, whose wrapper carries this
* rhythm and outranks the per-element margins; the editor renders through
* ProseMirror, which has no such wrapper. When the two disagree, clicking
* into a note visibly shifts every block after the first — the failure this
* pins. Tailwind's JIT only sees literal strings, so the editor cannot
* compose its variants from the constant; this asserts the mirror instead.
*/
for (const rule of NOTE_MARKDOWN_FLOW.split(' ')) {
const mirrored = rule.startsWith('[&>')
? `[&_.ProseMirror>${rule.slice('[&>'.length)}`
: `[&_.ProseMirror]:${rule}`
expect(NOTE_EDITOR_PROSE_CLASS_NAME).toContain(mirrored)
}
})
})
@@ -0,0 +1,83 @@
'use client'
import { cn } from '@sim/emcn'
import type { NoteContentEditorProps } from '@sim/workflow-renderer'
import { RichMarkdownField } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
import { useNoteImageUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/use-note-image-upload'
export const NOTE_EDITOR_PROSE_CLASS_NAME = [
'min-h-full w-full text-current',
/* The height the shared node chrome centres a checklist's checkbox against —
the note's own line box, not the file editor's taller default. */
'[--rich-markdown-line-height:1.25rem]',
/* Mirrors NOTE_MARKDOWN_FLOW, the rhythm Streamdown paints in the read view.
Tailwind's JIT only sees literal class strings, so this cannot be composed
from that constant — the note view exports it, and the parity test pins the
two together. Without it the editor falls back to the per-element margins
and every block after the first sits ~12px higher than when viewing. */
'[&_.ProseMirror]:space-y-4 [&_.ProseMirror>*:first-child]:mt-0 [&_.ProseMirror>*:last-child]:mb-0',
'[&_.ProseMirror]:min-h-full [&_.ProseMirror]:break-words [&_.ProseMirror]:pt-0.5 [&_.ProseMirror]:pb-2 [&_.ProseMirror]:outline-none',
'[&_.ProseMirror_p]:mb-1 [&_.ProseMirror_p]:text-sm [&_.ProseMirror_p]:leading-[1.25rem] [&_.ProseMirror_p:last-child]:mb-0',
'[&_.ProseMirror_h1]:mt-3 [&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:font-semibold [&_.ProseMirror_h1]:text-lg [&_.ProseMirror_h1:first-child]:mt-0',
'[&_.ProseMirror_h2]:mt-2.5 [&_.ProseMirror_h2]:mb-2.5 [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:text-base [&_.ProseMirror_h2:first-child]:mt-0',
'[&_.ProseMirror_h3]:mt-2 [&_.ProseMirror_h3]:mb-2 [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_h3]:text-sm [&_.ProseMirror_h3:first-child]:mt-0',
'[&_.ProseMirror_h4]:mt-2 [&_.ProseMirror_h4]:mb-2 [&_.ProseMirror_h4]:font-semibold [&_.ProseMirror_h4]:text-xs [&_.ProseMirror_h4:first-child]:mt-0',
'[&_.ProseMirror_ul]:mt-1 [&_.ProseMirror_ul]:mb-1 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:space-y-1 [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ul]:text-sm',
'[&_.ProseMirror_ol]:mt-1 [&_.ProseMirror_ol]:mb-1 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:space-y-1 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_ol]:text-sm',
'[&_.ProseMirror_li]:break-words [&_.ProseMirror_li>p]:mb-0',
'[&_.ProseMirror_code]:whitespace-normal [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:bg-black/10 [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-xs',
'[&_.ProseMirror_pre]:my-2 [&_.ProseMirror_pre]:whitespace-pre-wrap [&_.ProseMirror_pre]:break-words [&_.ProseMirror_pre]:rounded [&_.ProseMirror_pre]:bg-black/15 [&_.ProseMirror_pre]:p-2 [&_.ProseMirror_pre]:text-xs',
'[&_.ProseMirror_pre_code]:block [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0',
'[&_.ProseMirror_a]:break-all [&_.ProseMirror_a]:font-medium [&_.ProseMirror_a]:underline [&_.ProseMirror_a]:underline-offset-2',
/* The shared node chrome frames an image for a document surface — an 8px radius and a
`--border` hairline. A note paints a coloured card and draws every other edge from
`currentColor`, and its read view frames images this way, so matching here is what keeps the
image from changing shape as editing opens. Sizing (`max-width`/`height: auto`) still comes
from the shared chrome; only the frame is the note's. */
'[&_.ProseMirror_img]:rounded-md [&_.ProseMirror_img]:border-0',
'[&_.ProseMirror_strong]:font-semibold',
'[&_.ProseMirror_em]:opacity-80',
'[&_.ProseMirror_blockquote]:my-4 [&_.ProseMirror_blockquote]:border-current/25 [&_.ProseMirror_blockquote]:border-l-2 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:italic',
'[&_.ProseMirror_table]:my-2 [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:text-xs',
'[&_.ProseMirror_th]:px-2 [&_.ProseMirror_th]:py-1 [&_.ProseMirror_th]:text-left [&_.ProseMirror_th]:font-semibold',
'[&_.ProseMirror_td]:px-2 [&_.ProseMirror_td]:py-1 [&_.ProseMirror_td]:opacity-90',
'[&_.ProseMirror_.is-editor-empty:first-child]:before:pointer-events-none [&_.ProseMirror_.is-editor-empty:first-child]:before:float-left [&_.ProseMirror_.is-editor-empty:first-child]:before:h-0 [&_.ProseMirror_.is-editor-empty:first-child]:before:text-current/55 [&_.ProseMirror_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]',
].join(' ')
/**
* The Note card's skin over the platform markdown field.
*
* Everything behind the surface — the TipTap extension set, frontmatter held
* out-of-band, the round-trip safety gate and its raw-source fallback, the
* formatting bar, `/` commands, markdown paste — is {@link RichMarkdownField}'s.
* This module supplies the Note's own chrome: the type scale and per-colour
* selection/caret tints, which have to match the Streamdown render underneath so
* the card does not jump when editing opens, and the workspace it uploads into.
*
* Every field of {@link NoteContentEditorProps} is forwarded. A prop the view
* passes and this skin quietly drops type-checks clean and fails silently on the
* canvas, so `note-markdown-editor.test.tsx` pins the forwarding.
*/
export function NoteMarkdownEditor({
value,
selectionClassName,
caretClassName,
openedAt,
onChange,
}: NoteContentEditorProps) {
const uploadImage = useNoteImageUpload()
return (
<RichMarkdownField
value={value}
onChange={onChange}
surface='bare'
autoFocus
autoFocusAt={openedAt}
placeholder="Add note, or press '/' for commands…"
proseClassName={cn(NOTE_EDITOR_PROSE_CLASS_NAME, selectionClassName)}
editorClassName={caretClassName}
uploadImage={uploadImage}
/>
)
}
@@ -0,0 +1,35 @@
'use client'
import { useCallback } from 'react'
import { useParams } from 'next/navigation'
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
/**
* Uploads an image for a canvas Note and returns its hosted URL, or null if the
* upload failed.
*
* The workspace-file pipeline verbatim — presigned upload to S3, a
* `workspace_files` row, and the same workspace-scoped inline URL the file
* editor persists — so a note's images sit behind workspace auth and inside the
* existing storage lifecycle rather than a note-only namespace. `folderId: null`
* lands them in the Files root, where they stay visible and deletable like any
* other upload. The mutation owns its own success/failure toasts.
*
* Shared by the two surfaces that can add one: the editor's paste/drop/`/image`
* flow and the canvas context menu's "Add image".
*/
export function useNoteImageUpload(): (file: File) => Promise<{ url: string; alt: string } | null> {
const params = useParams()
const workspaceId = params.workspaceId as string
const uploadFile = useUploadWorkspaceFile()
return useCallback(
async (file: File) => {
const result = await uploadFile
.mutateAsync({ workspaceId, file, folderId: null })
.catch(() => null)
return result?.file ? { url: result.file.url, alt: file.name } : null
},
[uploadFile.mutateAsync, workspaceId]
)
}
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Button, Tooltip } from '@sim/emcn'
import { Chip, Tooltip } from '@sim/emcn'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal'
import {
useChangeDetection,
@@ -16,16 +16,10 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DeployProps {
activeWorkflowId: string | null
userPermissions: WorkspaceUserPermissions
className?: string
disabled?: boolean
}
export function Deploy({
activeWorkflowId,
userPermissions,
className,
disabled = false,
}: DeployProps) {
export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: DeployProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
@@ -123,17 +117,14 @@ export function Deploy({
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span>
<Button
className='h-[30px] gap-1.5 px-2.5'
variant={
isRegistryLoading ? 'active' : changeDetected || !isDeployed ? 'tertiary' : 'active'
}
<span className='inline-flex'>
<Chip
variant='border'
onClick={onDeployClick}
disabled={isRegistryLoading || isDisabled}
>
{getButtonLabel()}
</Button>
</Chip>
</span>
</Tooltip.Trigger>
<Tooltip.Content>{getTooltipText()}</Tooltip.Content>
@@ -1,3 +1,4 @@
export { ConnectionBlocks } from './connection-blocks/connection-blocks'
export { RetrySettings } from './retry-settings/retry-settings'
export { SubBlock } from './sub-block/sub-block'
export { SubflowEditor } from './subflow-editor/subflow-editor'
@@ -0,0 +1,55 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RetrySettings } from './retry-settings'
const policy = { enabled: true as const, maxTries: 5, waitBetweenTriesMs: 2000 }
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderSettings(props: Partial<Parameters<typeof RetrySettings>[0]> = {}) {
const onChange = vi.fn()
act(() => {
root.render(<RetrySettings retry={policy} disabled={false} onChange={onChange} {...props} />)
})
return { onChange }
}
const field = (id: string) => container.querySelector<HTMLInputElement>(`#${id}`)
describe('RetrySettings', () => {
it('leaves a configured value alone when the field is blurred untouched', () => {
const { onChange } = renderSettings()
const maxTries = field('block-retry-max-tries')!
expect(maxTries.value).toBe('5')
act(() => {
maxTries.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
})
expect(onChange).not.toHaveBeenCalled()
expect(field('block-retry-max-tries')!.value).toBe('5')
})
it('renders only the switch while retry is off', () => {
renderSettings({ retry: { ...policy, enabled: false } })
expect(field('block-retry-enabled')).not.toBeNull()
expect(field('block-retry-max-tries')).toBeNull()
})
})
@@ -0,0 +1,132 @@
'use client'
import { useState } from 'react'
import { ChipInput, FieldDivider, Label, Switch } from '@sim/emcn'
import {
BLOCK_RETRY_DEFAULT_TRIES,
BLOCK_RETRY_DEFAULT_WAIT_MS,
type BlockRetryConfig,
normalizeBlockRetryTries,
normalizeBlockRetryWaitMs,
} from '@sim/workflow-types/workflow'
interface RetrySettingsProps {
retry: BlockRetryConfig | undefined
disabled: boolean
onChange: (retry: BlockRetryConfig) => void
}
interface RetryNumberFieldProps {
id: string
title: string
value: number
disabled: boolean
normalize: (value: unknown) => number
onCommit: (value: number) => void
}
/**
* A bounded number field that commits on blur.
*
* Typed as text with a numeric input mode rather than `type='number'`: the
* native spinner is all that buys, and it does not fit the field chrome the rest
* of the panel uses. Bounds are applied on commit through the same normalizer
* execution uses, so the field cannot clamp differently from the executor.
*
* The draft exists only while the field is being edited; clearing it on commit
* lets an external change — a collaborator's edit, or an undo — flow straight
* through on the next render with no resync.
*/
function RetryNumberField({
id,
title,
value,
disabled,
normalize,
onCommit,
}: RetryNumberFieldProps) {
const [draft, setDraft] = useState<string | null>(null)
const commit = () => {
/** Untouched field: nothing was typed, so there is nothing to normalize or write. */
if (draft === null) return
const next = normalize(draft)
setDraft(null)
if (next !== value) onCommit(next)
}
return (
<div className='subblock-content flex flex-col gap-2.5'>
<Label htmlFor={id}>{title}</Label>
<ChipInput
id={id}
type='text'
inputMode='numeric'
value={draft ?? String(value)}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
disabled={disabled}
/>
</div>
)
}
/**
* Per-block retry, rendered as ordinary rows among the block's other additional
* fields so it carries the same label, spacing, and dividers.
*
* The numbers stay mounted only while retry is on, but the policy is written
* with `enabled: false` when it is switched off, so turning it back on restores
* what was configured rather than snapping to the defaults.
*/
export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps) {
const enabled = retry?.enabled === true
const maxTries = retry?.maxTries ?? BLOCK_RETRY_DEFAULT_TRIES
const waitBetweenTriesMs = retry?.waitBetweenTriesMs ?? BLOCK_RETRY_DEFAULT_WAIT_MS
const setPolicy = (next: Partial<BlockRetryConfig>) =>
onChange({ enabled, maxTries, waitBetweenTriesMs, ...next })
return (
<>
<div className='subblock-row'>
<div className='subblock-content flex items-center gap-x-3'>
<Switch
id='block-retry-enabled'
checked={enabled}
onCheckedChange={(next) => setPolicy({ enabled: next })}
disabled={disabled}
/>
<Label htmlFor='block-retry-enabled'>Retry on fail</Label>
</div>
{enabled && <FieldDivider subblockMarker />}
</div>
{enabled && (
<>
<div className='subblock-row'>
<RetryNumberField
id='block-retry-max-tries'
title='Max tries'
value={maxTries}
disabled={disabled}
normalize={normalizeBlockRetryTries}
onCommit={(next) => setPolicy({ maxTries: next })}
/>
<FieldDivider subblockMarker />
</div>
<div className='subblock-row'>
<RetryNumberField
id='block-retry-wait'
title='Wait between tries (ms)'
value={waitBetweenTriesMs}
disabled={disabled}
normalize={normalizeBlockRetryWaitMs}
onCommit={(next) => setPolicy({ waitBetweenTriesMs: next })}
/>
</div>
</>
)}
</>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, cn, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@sim/emcn'
import { Button, ChipTag, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@sim/emcn'
import {
BookOpen,
Check,
@@ -12,12 +12,15 @@ import {
SquareArrowUpRight,
Unlock,
} from '@sim/emcn/icons'
import { getWorkflowTypeAccent } from '@sim/workflow-renderer'
import type { BlockRetryConfig } from '@sim/workflow-types/workflow'
import { isEqual } from 'es-toolkit'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { captureEvent } from '@/lib/posthog/client'
import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
@@ -30,6 +33,7 @@ import {
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ConnectionBlocks,
RetrySettings,
SubBlock,
SubflowEditor,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
@@ -50,7 +54,7 @@ import {
isBlockProtected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { isLightTileColor } from '@/blocks/icon-color'
import { getBlock } from '@/blocks/registry'
import { useFolderMap } from '@/hooks/queries/folders'
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
@@ -83,19 +87,27 @@ const IconComponent = ({ icon: Icon, className }: { icon: any; className?: strin
* @returns Editor panel content
*/
export function Editor() {
const { currentBlockId, connectionsHeight, toggleConnectionsCollapsed, registerRenameCallback } =
usePanelEditorStore(
useShallow((state) => ({
currentBlockId: state.currentBlockId,
connectionsHeight: state.connectionsHeight,
toggleConnectionsCollapsed: state.toggleConnectionsCollapsed,
registerRenameCallback: state.registerRenameCallback,
}))
)
const {
currentBlockId,
connectionsHeight,
toggleConnectionsCollapsed,
registerRenameCallback,
clearCurrentBlock,
} = usePanelEditorStore(
useShallow((state) => ({
currentBlockId: state.currentBlockId,
connectionsHeight: state.connectionsHeight,
toggleConnectionsCollapsed: state.toggleConnectionsCollapsed,
registerRenameCallback: state.registerRenameCallback,
clearCurrentBlock: state.clearCurrentBlock,
}))
)
const activeSearchTarget = usePanelEditorSearchStore((state) => state.activeSearchTarget)
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
const typeAccent = getWorkflowTypeAccent(currentBlock?.type ?? '')
const isIntegration = blockConfig?.category === 'tools'
const title = currentBlock?.name || 'Editor'
const isBlockNameSearchHighlighted =
activeSearchTarget?.targetKind === 'block-name' && activeSearchTarget.blockId === currentBlockId
@@ -116,6 +128,11 @@ export function Editor() {
const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
const isNoteBlock = currentBlock?.type === 'note'
useEffect(() => {
if (isNoteBlock) clearCurrentBlock()
}, [clearCurrentBlock, isNoteBlock])
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -180,9 +197,24 @@ export function Editor() {
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
)
/**
* Whether the additional-fields disclosure is open, held as view state only.
*
* Deliberately not written back to `block.advancedMode`: that flag also decides
* which member of a canonical pair serializes, so driving it from this control
* would change the credential a block sends just because someone opened a
* disclosure. Seeded from the stored flag so a block saved while it was on
* still opens expanded.
*/
const [additionalFieldsExpanded, setAdditionalFieldsExpanded] = useState(advancedMode)
useEffect(() => {
setAdditionalFieldsExpanded(advancedMode)
}, [advancedMode, currentBlockId])
const displayAdvancedOptions = canEditBlock
? advancedMode || activeSearchTargetNeedsAdvanced
: advancedMode || advancedValuesPresent || activeSearchTargetNeedsAdvanced
? additionalFieldsExpanded || activeSearchTargetNeedsAdvanced
: additionalFieldsExpanded || advancedValuesPresent || activeSearchTargetNeedsAdvanced
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
@@ -243,14 +275,32 @@ export function Editor() {
const {
collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode,
collaborativeSetBlockRetry,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !canEditBlock) return
collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
const handleToggleAdditionalFields = useCallback(() => {
if (!canEditBlock) return
setAdditionalFieldsExpanded((expanded) => !expanded)
}, [canEditBlock])
const supportsRetry = isRetryEligibleBlock({
blockType: currentBlock?.type,
category: blockConfig?.category,
triggerMode,
})
const showRetrySettings = supportsRetry && displayAdvancedOptions
/** Retry lives in the additional-fields disclosure, which a block may otherwise have no reason to show. */
const hasAdditionalFields = hasAdvancedOnlyFields || supportsRetry
const handleChangeRetry = useCallback(
(retry: BlockRetryConfig) => {
if (!currentBlockId) return
collaborativeSetBlockRetry(currentBlockId, retry)
},
[currentBlockId, collaborativeSetBlockRetry]
)
const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('')
@@ -337,6 +387,22 @@ export function Editor() {
return () => registerRenameCallback(null)
}, [registerRenameCallback, handleStartRename])
/**
* Drops a rename whose block the panel is no longer showing.
*
* `renamingBlockIdRef` decides what {@link handleSaveRename} writes, and the
* header keeps rendering the input regardless of which block is selected — so
* a rename left running across a selection change puts one block's name in
* front of another and saves it to the first. Nothing else ends a rename on
* selection: the input's blur only fires if it held focus.
*/
useEffect(() => {
if (!renamingBlockIdRef.current || renamingBlockIdRef.current === currentBlockId) return
renamingBlockIdRef.current = null
setIsRenaming(false)
setEditedName('')
}, [currentBlockId])
/**
* Handles opening documentation link in a new secure tab.
*/
@@ -365,25 +431,29 @@ export function Editor() {
const isConnectionsAtMinHeight = connectionsHeight <= 35
if (isNoteBlock) return null
return (
<ActiveSearchTargetProvider value={activeSearchTargetForCurrentBlock}>
<div className='flex h-full flex-col'>
{/* Header */}
<div className='mx-[-1px] flex flex-shrink-0 items-center justify-between rounded-none border border-[var(--border)] bg-[var(--surface-4)] px-3 py-1.5'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
<div
className='flex size-[18px] items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: isSubflow ? subflowConfig?.bgColor : blockConfig?.bgColor }}
{(blockConfig || isSubflow) && (
<ChipTag
variant={isIntegration ? 'brand' : typeAccent.variant}
tone={isIntegration ? undefined : typeAccent.tone}
brandColor={isIntegration ? blockConfig.bgColor : undefined}
brandForeground={
isIntegration && isLightTileColor(blockConfig.bgColor) ? 'dark' : 'light'
}
className='size-[18px] justify-center px-0'
>
<IconComponent
icon={isSubflow ? subflowConfig?.icon : blockConfig?.icon}
className={cn(
'size-[12px]',
getTileIconColorClass(isSubflow ? subflowConfig?.bgColor : blockConfig?.bgColor)
)}
className='size-[12px]'
/>
</div>
</ChipTag>
)}
{isRenaming ? (
<input
@@ -614,7 +684,7 @@ export function Editor() {
const showDivider =
index < regularSubBlocks.length - 1 ||
(!hasAdvancedOnlyFields && index < subBlocks.length - 1)
(!hasAdditionalFields && index < subBlocks.length - 1)
return (
<div key={stableKey} className='subblock-row'>
@@ -664,12 +734,12 @@ export function Editor() {
)
})}
{hasAdvancedOnlyFields && canEditBlock && (
{hasAdditionalFields && canEditBlock && (
<div className='flex items-center gap-2.5 px-0.5 pt-3.5 pb-3'>
<DashedDividerLine className='flex-1' />
<button
type='button'
onClick={handleToggleAdvancedMode}
onClick={handleToggleAdditionalFields}
className='flex items-center gap-1.5 whitespace-nowrap text-[var(--text-secondary)] text-small hover-hover:text-[var(--text-primary)]'
>
{displayAdvancedOptions
@@ -682,7 +752,7 @@ export function Editor() {
<DashedDividerLine className='flex-1' />
</div>
)}
{hasAdvancedOnlyFields && !canEditBlock && displayAdvancedOptions && (
{hasAdditionalFields && !canEditBlock && displayAdvancedOptions && (
<div className='flex items-center gap-2.5 px-0.5 pt-3.5 pb-3'>
<DashedDividerLine className='flex-1' />
<span className='whitespace-nowrap text-[var(--text-secondary)] text-small'>
@@ -715,7 +785,7 @@ export function Editor() {
(subBlock.canonicalParamId ?? subBlock.id))
}
/>
{index < advancedOnlySubBlocks.length - 1 && (
{(index < advancedOnlySubBlocks.length - 1 || showRetrySettings) && (
<FieldDivider
subblockMarker
className={
@@ -728,6 +798,14 @@ export function Editor() {
</div>
)
})}
{showRetrySettings && (
<RetrySettings
retry={currentBlock?.retry}
disabled={!canEditBlock}
onChange={handleChangeRetry}
/>
)}
</div>
)}
</div>
@@ -4,7 +4,7 @@
export interface DragItemInfo {
name: string
bgColor: string
iconElement?: HTMLElement | null
iconContainer?: HTMLElement | null
}
/**
@@ -32,25 +32,23 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
z-index: 9999;
`
const iconContainer = document.createElement('div')
iconContainer.style.cssText = `
width: 24px;
height: 24px;
border-radius: 6px;
background: ${info.bgColor};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`
const iconContainer = info.iconContainer
? (info.iconContainer.cloneNode(true) as HTMLElement)
: document.createElement('div')
iconContainer.style.width = '24px'
iconContainer.style.height = '24px'
iconContainer.style.borderRadius = '6px'
iconContainer.style.display = 'flex'
iconContainer.style.alignItems = 'center'
iconContainer.style.justifyContent = 'center'
iconContainer.style.flexShrink = '0'
if (!info.iconContainer) iconContainer.style.background = info.bgColor
if (info.iconElement) {
const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement
const clonedIcon = iconContainer.querySelector<HTMLElement>('svg, img')
if (clonedIcon) {
clonedIcon.style.width = '16px'
clonedIcon.style.height = '16px'
clonedIcon.style.color = 'white'
clonedIcon.style.flexShrink = '0'
iconContainer.appendChild(clonedIcon)
}
const text = document.createElement('span')
@@ -47,13 +47,7 @@ export function ToolbarItemContextMenu({
showViewDocumentation = false,
}: ToolbarItemContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()} size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
@@ -21,12 +21,16 @@ import {
Info,
} from '@sim/emcn'
import { ChevronDown, Search } from '@sim/emcn/icons'
import { hasWorkflowTypeRole, WorkflowTypeIcon } from '@sim/workflow-renderer'
import clsx from 'clsx'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components'
import {
type DragItemInfo,
ToolbarItemContextMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components'
import { useToolbarItemInteractions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
@@ -53,6 +57,7 @@ interface BlockItem {
config?: BlockConfig
icon?: ComponentType<{ className?: string }>
bgColor?: string
workflowType?: string
docsLink?: string
}
@@ -63,7 +68,7 @@ interface ToolbarItemProps {
e: React.DragEvent<HTMLElement>,
type: string,
enableTriggerMode: boolean,
dragItemInfo?: { name: string; bgColor: string; iconElement: HTMLElement | null }
dragItemInfo?: DragItemInfo
) => void
onClick: (type: string, enableTriggerMode: boolean) => void
onContextMenu: (e: React.MouseEvent, type: string, isTrigger: boolean, docsLink?: string) => void
@@ -83,11 +88,11 @@ const ToolbarItem = memo(function ToolbarItem({
const handleDragStart = useCallback(
(e: React.DragEvent<HTMLElement>) => {
const iconElement = e.currentTarget.querySelector('.toolbar-item-icon')
const iconContainer = e.currentTarget.querySelector<HTMLElement>('[data-toolbar-item-icon]')
onDragStart(e, item.type, isTriggerCapable, {
name: item.name,
bgColor: item.bgColor ?? '#666666',
iconElement: iconElement as HTMLElement | null,
iconContainer,
})
},
[item.type, item.name, item.bgColor, isTriggerCapable, onDragStart]
@@ -129,21 +134,26 @@ const ToolbarItem = memo(function ToolbarItem({
)}
onKeyDown={handleKeyDown}
>
<div
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: item.bgColor }}
>
{Icon && (
<Icon
className={clsx(
'toolbar-item-icon transition-transform duration-200',
getTileIconColorClass(item.bgColor),
'group-hover:scale-110',
'size-[10px]'
)}
/>
)}
</div>
{item.workflowType && Icon ? (
<WorkflowTypeIcon type={item.workflowType} Icon={Icon} data-toolbar-item-icon='' />
) : (
<div
data-toolbar-item-icon=''
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: item.bgColor }}
>
{Icon && (
<Icon
className={clsx(
'transition-transform duration-200',
getTileIconColorClass(item.bgColor),
'group-hover:scale-110',
'size-[10px]'
)}
/>
)}
</div>
)}
<span className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{item.name}</span>
</div>
)
@@ -200,6 +210,7 @@ function getTriggers(overlayVersion: number): BlockItem[] {
config: trigger,
icon: trigger.icon,
bgColor: trigger.bgColor,
workflowType: hasWorkflowTypeRole(trigger.type) ? trigger.type : undefined,
docsLink: trigger.docsLink,
}))
}
@@ -236,6 +247,7 @@ function ensureBlockCaches() {
config: block,
icon: block.icon,
bgColor: block.bgColor,
workflowType: block.type,
}))
regularBlockItems.push({
@@ -243,6 +255,7 @@ function ensureBlockCaches() {
type: LoopTool.type,
icon: LoopTool.icon,
bgColor: LoopTool.bgColor,
workflowType: LoopTool.type,
docsLink: LoopTool.docsLink,
})
@@ -251,6 +264,7 @@ function ensureBlockCaches() {
type: ParallelTool.type,
icon: ParallelTool.icon,
bgColor: ParallelTool.bgColor,
workflowType: ParallelTool.type,
docsLink: ParallelTool.docsLink,
})
@@ -260,6 +274,7 @@ function ensureBlockCaches() {
config: block,
icon: block.icon,
bgColor: block.bgColor,
workflowType: hasWorkflowTypeRole(block.type) ? block.type : undefined,
}))
regularBlockItems.sort((a, b) => a.name.localeCompare(b.name))
@@ -5,6 +5,7 @@ import {
BubbleChatClose,
BubbleChatPreview,
Button,
Chip,
ChipConfirmModal,
DropdownMenu,
DropdownMenuContent,
@@ -13,7 +14,6 @@ import {
Duplicate,
Layout,
MoreHorizontal,
Play,
Popover,
PopoverContent,
PopoverItem,
@@ -23,7 +23,7 @@ import {
Trash,
toast,
} from '@sim/emcn'
import { BubbleChatDelay, Download, Lock, Plus, Square, Unlock } from '@sim/emcn/icons'
import { BubbleChatDelay, Download, Lock, Plus, Unlock } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
@@ -31,6 +31,7 @@ import { useParams, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useShallow } from 'zustand/react/shallow'
import { VariableIcon } from '@/components/icons'
import { ThinkingLoader } from '@/components/ui'
import { requestJson } from '@/lib/api/client/request'
import {
createWorkflowCopilotChatContract,
@@ -766,19 +767,35 @@ export const Panel = memo(function Panel() {
userPermissions={userPermissions}
disabled={workflowLocked}
/>
<Button
className='h-[30px] gap-2 px-2.5'
variant={isExecuting ? 'active' : 'tertiary'}
<Chip
variant={isExecuting ? undefined : 'primary'}
active={isExecuting}
onClick={isExecuting ? cancelWorkflow : () => runWorkflow()}
disabled={!isExecuting && isButtonDisabled}
aria-label={isExecuting ? 'Stop' : 'Run'}
leftAdornment={
<span
aria-hidden='true'
className='inline-flex size-5 flex-shrink-0 items-center justify-center overflow-visible'
>
<ThinkingLoader
variant={isExecuting ? undefined : 'play'}
startVariant='play'
startHoldMs={140}
size={20}
morphDurationMs={isExecuting ? 650 : 180}
tone='inherit'
/>
</span>
}
>
{isExecuting ? (
<Square className='h-[11.5px] w-[11.5px] fill-current' />
) : (
<Play className='h-[11.5px] w-[11.5px]' />
)}
{isExecuting ? 'Stop' : 'Run'}
</Button>
<span className='inline-grid'>
<span aria-hidden='true' className='invisible col-start-1 row-start-1'>
Stop
</span>
<span className='col-start-1 row-start-1'>{isExecuting ? 'Stop' : 'Run'}</span>
</span>
</Chip>
</div>
</div>
@@ -4,8 +4,11 @@ import { type NodeProps, useReactFlow } from 'reactflow'
import { hasDiffStatus } from '@/lib/workflows/diff/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useLastRunPath } from '@/stores/execution'
import {
useCurrentWorkflow,
useIsBlockInActiveExecutionHandoff,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useIsBlockActive, useIsCurrentWorkflowExecuting } from '@/stores/execution'
import { usePanelEditorStore } from '@/stores/panel'
/**
@@ -30,20 +33,20 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const isEnabled = currentBlock?.enabled ?? true
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const isFocused = currentBlockId === id
const lastRunPath = useLastRunPath()
const executionStatus = data.executionStatus
const runPathStatus: 'success' | 'error' | undefined =
executionStatus === 'success' || executionStatus === 'error'
? executionStatus
: isPreview
? undefined
: lastRunPath.get(id)
/*
* Three separate signals, deliberately. `isRunning` and
* `isExecutionHighlighted` are per-container and drive this node's own loader
* and border; `isWorkflowRunning` only swaps Run for Stop and disables
* mutations. Driving the visuals off the workflow instead would light up
* every node on the canvas for the whole run.
*/
const isWorkflowRunning = useIsCurrentWorkflowExecuting()
const isRunning = useIsBlockActive(id)
const isExecutionHighlighted = useIsBlockInActiveExecutionHandoff(id)
/**
* Nesting depth, walking the parent chain so the view can apply nested
@@ -71,12 +74,23 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
isEnabled={isEnabled}
isLocked={isLocked}
isFocused={isFocused}
runPathStatus={runPathStatus}
isRunning={isRunning}
isWorkflowRunning={isWorkflowRunning}
isExecutionHighlighted={isExecutionHighlighted}
diffStatus={diffStatus}
nestingLevel={nestingLevel}
canEditWorkflow={canEditWorkflow}
onSelect={() => setCurrentBlockId(id)}
actionBar={<ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
actionBar={
<ActionBar
blockId={id}
blockType={data.kind}
disabled={!canEditWorkflow}
variant='swell'
isRunning={isRunning}
isWorkflowRunning={isWorkflowRunning}
/>
}
/>
)
})
@@ -47,13 +47,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()} size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
@@ -44,13 +44,7 @@ export const OutputContextMenu = memo(function OutputContextMenu({
hasSelection,
}: OutputContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()} size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
@@ -18,6 +18,10 @@ export interface WorkflowBlockProps {
isWorkflowLocked?: boolean
subBlockValues?: Record<string, any>
blockState?: any
/** Persists and broadcasts the Error output toggle. */
onSetErrorOutputEnabled?: (blockId: string, enabled: boolean) => void
/** Persists and broadcasts edge removals caused by disabling Error output. */
onRemoveEdges?: (edgeIds: string[]) => void
}
/**
@@ -40,6 +40,8 @@ export function shouldSkipBlockRender(
prevProps.data.config === nextProps.data.config &&
prevProps.data.subBlockValues === nextProps.data.subBlockValues &&
prevProps.data.blockState === nextProps.data.blockState &&
prevProps.data.onSetErrorOutputEnabled === nextProps.data.onSetErrorOutputEnabled &&
prevProps.data.onRemoveEdges === nextProps.data.onRemoveEdges &&
prevProps.selected === nextProps.selected &&
prevProps.dragging === nextProps.dragging
)
@@ -1,10 +1,32 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
import { type ComponentType, Fragment, memo, useCallback, useEffect, useMemo, useRef } from 'react'
import {
BLOCK_DIMENSIONS,
CanvasSentenceView,
SubBlockRowView,
WorkflowBlockView,
} from '@sim/workflow-renderer'
import { wouldCreateCycle } from '@sim/workflow-types/workflow'
import { isEqual } from 'es-toolkit'
import {
ArrowLeftRight,
ArrowUpDown,
Braces,
Clock,
Globe,
Hash,
KeyRound,
ListFilter,
MessageSquareText,
Paperclip,
SkipForward,
SlidersHorizontal,
Sparkles,
ToggleLeft,
Wrench,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { type NodeProps, useUpdateNodeInternals } from 'reactflow'
import { type NodeProps, useStore as useReactFlowStore, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -12,10 +34,25 @@ import { createMcpToolId } from '@/lib/mcp/shared'
import { sendMothershipMessage } from '@/lib/mothership/events'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { captureEvent } from '@/lib/posthog/client'
import { getCardSubBlocks } from '@/lib/workflows/blocks/canvas-card-fields'
import { resolveCanvasBlockPresentation } from '@/lib/workflows/blocks/canvas-presentation'
import {
showsCanvasDefaultHandles,
showsCanvasErrorRow,
splitCanvasChipBlocks,
} from '@/lib/workflows/blocks/canvas-rows'
import {
type CardSelector,
estimateSentenceLines,
getOperationSubBlockId,
resolveCanvasSentence,
} from '@/lib/workflows/blocks/canvas-sentence'
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
getDisplayValue,
hasDisplayableRowValue,
resolveDropdownLabel,
resolveFilterFieldLabel,
resolveSandboxLabel,
@@ -27,13 +64,7 @@ import {
} from '@/lib/workflows/subblocks/display'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockHidden,
isSubBlockVisibleForMode,
isToolInputOnlySubBlock,
isTriggerModeSubBlock,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -48,7 +79,10 @@ import {
getProviderName,
shouldSkipBlockRender,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
useBlockVisual,
useIsBlockInActiveExecutionHandoff,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getBlock } from '@/blocks/registry'
@@ -71,13 +105,13 @@ import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useReactiveConditions } from '@/hooks/use-reactive-conditions'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { getModelSunsetStatus } from '@/providers/models'
import { useIsCurrentWorkflowExecuting } from '@/stores/execution'
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('WorkflowBlock')
import { TRIGGER_REGISTRY } from '@/triggers/registry'
/** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
@@ -85,6 +119,65 @@ const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/** Stable empty map for rows that never resolve MCP tool names */
const EMPTY_MCP_TOOL_NAMES: ReadonlyMap<string, string> = new Map()
type MetaIcon = ComponentType<{ className?: string }>
/** Leading icons for compact meta rows, keyed by subblock id. */
const SUBBLOCK_META_ICONS_BY_ID: Record<string, MetaIcon> = {
filterBuilder: ListFilter,
bulkFilterBuilder: ListFilter,
filter: ListFilter,
filterCriteria: ListFilter,
sortBuilder: ArrowUpDown,
sort: ArrowUpDown,
limit: Hash,
offset: SkipForward,
rowId: KeyRound,
url: Globe,
method: ArrowLeftRight,
body: Braces,
data: Braces,
}
/** Leading icons for compact meta rows, keyed by subblock type. */
const SUBBLOCK_META_ICONS_BY_TYPE: Record<string, MetaIcon> = {
code: Braces,
'messages-input': MessageSquareText,
'tool-input': Wrench,
'skill-input': Sparkles,
'oauth-input': KeyRound,
switch: ToggleLeft,
'file-upload': Paperclip,
'time-input': Clock,
slider: SlidersHorizontal,
}
/** Resolves the meta-row icon for a subblock; null keeps the labeled row. */
function getMetaIcon(subBlock: SubBlockConfig): MetaIcon | null {
return (
SUBBLOCK_META_ICONS_BY_ID[subBlock.id] ?? SUBBLOCK_META_ICONS_BY_TYPE[subBlock.type] ?? null
)
}
/** Usable sentence width inside the card: the 250px card less its `p-2`. */
const SENTENCE_WRAP_WIDTH_PX =
BLOCK_DIMENSIONS.FIXED_WIDTH - BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING
/**
* Names of MCP tool-schema parameters whose argument values are displayable
* on the collapsed node. Params without a set value are hidden from the
* preview, matching the empty-row filtering applied to regular subblocks.
*/
function getDisplayableMcpParamNames(schemaValue: unknown, argsValue: unknown): string[] {
const schema = schemaValue as { properties?: Record<string, unknown> } | undefined
const properties = schema?.properties
if (!properties || typeof properties !== 'object') return []
const args = (argsValue && typeof argsValue === 'object' ? argsValue : {}) as Record<
string,
unknown
>
return Object.keys(properties).filter((name) => getDisplayValue(args[name]) !== '-')
}
interface BlockSunset {
status: 'legacy' | 'deprecated'
kind: 'block' | 'model'
@@ -171,6 +264,10 @@ interface SubBlockRowProps {
displayAdvancedOptions?: boolean
canonicalIndex?: ReturnType<typeof buildCanonicalIndex>
canonicalModeOverrides?: Record<string, 'basic' | 'advanced'>
/** Presentation variant forwarded to the row view. */
variant?: 'row' | 'meta' | 'statement-primary' | 'statement-muted' | 'inline-value'
/** Leading icon forwarded to the row view (meta variant). */
icon?: MetaIcon
}
/**
@@ -196,7 +293,9 @@ const areSubBlockRowPropsEqual = (
valueEqual &&
prevProps.displayAdvancedOptions === nextProps.displayAdvancedOptions &&
prevProps.canonicalIndex === nextProps.canonicalIndex &&
prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides
prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides &&
prevProps.variant === nextProps.variant &&
prevProps.icon === nextProps.icon
)
}
@@ -217,16 +316,9 @@ const SubBlockRow = memo(function SubBlockRow({
displayAdvancedOptions,
canonicalIndex,
canonicalModeOverrides,
variant,
icon,
}: SubBlockRowProps) {
const getStringValue = useCallback(
(key?: string): string | undefined => {
if (!key || !allSubBlockValues) return undefined
const candidate = allSubBlockValues[key]?.value
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
},
[allSubBlockValues]
)
const rawValues = useMemo(() => {
if (!allSubBlockValues) return {}
return Object.entries(allSubBlockValues).reduce<Record<string, unknown>>(
@@ -486,7 +578,13 @@ const SubBlockRow = memo(function SubBlockRow({
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return (
<SubBlockRowView title={title} displayValue={displayValue} isMonospace={isMonospaceField} />
<SubBlockRowView
title={title}
displayValue={displayValue}
isMonospace={isMonospaceField}
variant={variant}
icon={icon}
/>
)
}, areSubBlockRowPropsEqual)
@@ -506,6 +604,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow,
activeWorkflowId,
isEnabled,
isExecuting,
isLocked,
handleClick,
hasRing,
@@ -513,6 +612,15 @@ export const WorkflowBlock = memo(function WorkflowBlock({
runPathStatus,
} = useBlockVisual({ blockId: id, data, isPending, isSelected: selected })
/*
* Three separate signals, deliberately. `isExecuting` (per-block, from
* `useBlockVisual`) and `isExecutionHighlighted` drive this card's own loader
* and border; `isWorkflowRunning` only swaps Run for Stop and disables
* mutations. Driving the visuals off the workflow instead would light up
* every card on the canvas for the whole run.
*/
const isWorkflowRunning = useIsCurrentWorkflowExecuting()
const isExecutionHighlighted = useIsBlockInActiveExecutionHandoff(id)
const currentWorkflowId = (params.workflowId as string) || activeWorkflowId || ''
const currentBlock = currentWorkflow.getBlockById(id)
@@ -581,6 +689,85 @@ export const WorkflowBlock = memo(function WorkflowBlock({
isEqual
)
/**
* Whether a persisted legacy error route is wired from this block. The
* renderer uses this only to retain a non-interactive edge anchor.
*/
const hasErrorConnection = useWorkflowStore(
useCallback(
(state) => state.edges.some((edge) => edge.source === id && edge.sourceHandle === 'error'),
[id]
)
)
/**
* Handle ids whose connected edge is highlighted because an endpoint block
* is selected — the view darkens those tabs to the edge highlight color so
* port and line read as one piece. Serialized to a string so the ReactFlow
* store subscription only re-renders on real changes.
*/
const editorBlockId = usePanelEditorStore((state) => state.currentBlockId)
const panelActiveTab = usePanelStore((state) => state.activeTab)
const editorOpenBlockId = panelActiveTab === 'editor' ? editorBlockId : null
const highlightedHandleKey = useReactFlowStore(
useCallback(
(state) => {
const keys: string[] = []
for (const edge of state.edges) {
if (edge.source !== id && edge.target !== id) continue
/*
* Must mirror workflow-edge's shouldHighlightEdge exactly: the edge
* darkens when an endpoint is canvas-selected OR open in the editor
* panel. If the knob checks fewer conditions than the line, a dark
* line runs into a light knob.
*/
const isHighlighted =
state.nodeInternals.get(edge.source)?.selected ||
state.nodeInternals.get(edge.target)?.selected ||
(edge.data as { isConnectedToSelection?: boolean } | undefined)
?.isConnectedToSelection ||
(editorOpenBlockId !== null &&
(edge.source === editorOpenBlockId || edge.target === editorOpenBlockId))
if (!isHighlighted) continue
if (edge.source === id) keys.push(edge.sourceHandle || 'source')
if (edge.target === id) keys.push(edge.targetHandle || 'target')
}
return keys.sort().join('|')
},
[id, editorOpenBlockId]
)
)
const highlightedHandles = useMemo(
() => new Set(highlightedHandleKey ? highlightedHandleKey.split('|') : []),
[highlightedHandleKey]
)
/*
* An existing error edge means the output is on, whatever the flag says.
* Every released version drew the error port with no toggle in front of it, so
* a block already wired that way had no other way to make the connection —
* reading the flag alone would unmount a port a live workflow routes failures
* through, and React Flow drops an edge whose handle is not mounted. The
* migration backfills those rows, but this keeps the rule true for any state
* that reaches the canvas without passing through it (an imported workflow, a
* deployment snapshot, a copilot edit).
*/
const errorOutputEnabled = Boolean(currentBlock?.errorEnabled || hasErrorConnection)
const handleToggleErrorOutput = useCallback(
(next: boolean) => {
const store = useWorkflowStore.getState()
data.onSetErrorOutputEnabled?.(id, next)
if (!next) {
/* Turning the branch off removes its connections — a hidden error
edge would still reroute failures with no visible affordance. */
const errorEdgeIds = store.edges
.filter((edge) => edge.source === id && edge.sourceHandle === 'error')
.map((edge) => edge.id)
if (errorEdgeIds.length > 0) data.onRemoveEdges?.(errorEdgeIds)
}
},
[data.onRemoveEdges, data.onSetErrorOutputEnabled, id]
)
const posthog = usePostHog()
const sunset = getBlockSunset(config, name, blockSubBlockValues.model, currentWorkflow.isDiffMode)
@@ -640,51 +827,36 @@ export const WorkflowBlock = memo(function WorkflowBlock({
? displayAdvancedMode
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
const effectiveTrigger = displayTriggerMode
const canvasPresentation = resolveCanvasBlockPresentation(config, name, rawValues)
const visibleSubBlocks = config.subBlocks.filter((block) => {
if (block.hidden) return false
if (block.hideFromPreview) return false
if (hiddenByReactiveCondition.has(block.id)) return false
if (!isSubBlockFeatureEnabled(block)) return false
// Configures the block as an agent tool; it has no meaning on the canvas.
if (isToolInputOnlySubBlock(block)) return false
if (isSubBlockHidden(block)) return false
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? isTriggerModeSubBlock(block) || !block.mode
: isTriggerModeSubBlock(block)
if (!isValidTriggerSubblock) {
return false
}
} else {
if (isTriggerModeSubBlock(block)) {
return false
}
}
if (
!isSubBlockVisibleForMode(
block,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
if (!block.condition) return true
return evaluateSubBlockCondition(block.condition, rawValues)
/*
* Visible on the card, whether or not it holds a value. A sentence's core
* slots render either way — the chip shows the field's noun until it is
* filled — so the sentence needs this set, while the field rows below it
* need the value-bearing subset.
*/
const displayableSubBlocks = getCardSubBlocks(config, {
advanced: effectiveAdvanced,
values: rawValues,
canonicalIndex,
canonicalModeOverrides,
triggerMode: effectiveTrigger,
hiddenIds: hiddenByReactiveCondition,
titleOperationSubBlockId: canvasPresentation.titleShowsOperation
? canvasPresentation.operationSubBlockId
: null,
})
visibleSubBlocks.forEach((block) => {
const visibleSubBlocks = displayableSubBlocks.filter((block) =>
hasDisplayableRowValue(block, rawValues[block.id])
)
const { chipBlocks, rowSubBlocks } = splitCanvasChipBlocks(visibleSubBlocks, {
titleShowsOperation: canvasPresentation.titleShowsOperation,
operationSubBlockId: canvasPresentation.operationSubBlockId,
})
rowSubBlocks.forEach((block) => {
if (currentRowWidth + blockWidth > 1) {
if (currentRow.length > 0) {
rows.push([...currentRow])
@@ -701,7 +873,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
rows.push(currentRow)
}
return { rows, stateToUse }
return { rows, stateToUse, chipBlocks, canvasPresentation, displayableSubBlocks }
}, [
config.subBlocks,
config.category,
@@ -719,10 +891,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
hiddenByReactiveCondition,
blockSubBlockValues,
activeWorkflowId,
name,
])
const subBlockRows = subBlockRowsData.rows
const subBlockState = subBlockRowsData.stateToUse
const chipBlocks = subBlockRowsData.chipBlocks
const canvasPresentation = subBlockRowsData.canvasPresentation
const displayableSubBlocks = subBlockRowsData.displayableSubBlocks
const topologySubBlocks = data.isPreview
? (data.blockState?.subBlocks ?? {})
: (currentStoreBlock?.subBlocks ?? {})
@@ -739,13 +915,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
}, [subBlockState, displayAdvancedMode, config.subBlocks, canonicalIndex, canEditWorkflow])
/**
* Determine if block has content below the header (subblocks or error row).
* Controls header border visibility and content container rendering.
*/
const shouldShowDefaultHandles =
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
const shouldShowDefaultHandles = showsCanvasDefaultHandles(config, type, displayTriggerMode)
/**
* Compute per-condition rows (title/value/id) for condition blocks so we can render
@@ -772,6 +942,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
}))
}, [type, topologySubBlocks, id])
const showsErrorRow = showsCanvasErrorRow(config, type, displayTriggerMode)
/**
* Total rendered row count. `mcp-dynamic-args` expands one row per parameter
* in the cached tool schema, so we count those properties instead of 1.
@@ -781,11 +953,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
for (const row of subBlockRows) {
for (const subBlock of row) {
if (subBlock.type === 'mcp-dynamic-args') {
const schema = subBlockState._toolSchema?.value as
| { properties?: Record<string, unknown> }
| undefined
const properties = schema?.properties
count += properties && typeof properties === 'object' ? Object.keys(properties).length : 0
count += getDisplayableMcpParamNames(
subBlockState._toolSchema?.value,
subBlockState[subBlock.id]?.value
).length
} else {
count += 1
}
@@ -794,6 +965,87 @@ export const WorkflowBlock = memo(function WorkflowBlock({
return count
}, [subBlockRows, subBlockState])
/**
* Natural-language summary data: segments + line estimate for the block
* types with a sentence template. Null keeps the field-row layout.
*
* A card in trigger mode gets its own sentence, not the action one: the
* operation dropdown still holds its action-mode default, so reusing it would
* narrate an action the block is not going to run.
*/
const sentenceData = useMemo(() => {
if (type === 'condition' || type === 'router_v2') return null
const visibleSubBlocksById = new Map<string, SubBlockConfig>()
for (const subBlock of chipBlocks) visibleSubBlocksById.set(subBlock.id, subBlock)
for (const row of subBlockRows) {
for (const subBlock of row) visibleSubBlocksById.set(subBlock.id, subBlock)
}
/*
* The operation is read straight off state rather than through the visible
* set: it is filtered out of the rows whenever it supplies the card title,
* so it would never resolve.
*/
const operationSubBlockId = getOperationSubBlockId(config)
const rawValues: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(subBlockState)) rawValues[key] = entry?.value
const card: CardSelector = displayTriggerMode
? (() => {
const triggerId = resolveSelectedTriggerId(config, rawValues)
return {
mode: 'trigger' as const,
triggerId,
triggerName: triggerId ? (TRIGGER_REGISTRY[triggerId]?.name ?? null) : null,
}
})()
: {
mode: 'action',
operationValue: operationSubBlockId ? rawValues[operationSubBlockId] : undefined,
}
/* The definition this operation actually shows, so a core slot's noun comes
from the right one — ids repeat across operations with different titles. */
const onCardById = new Map<string, SubBlockConfig>()
for (const subBlock of displayableSubBlocks) {
if (!onCardById.has(subBlock.id)) onCardById.set(subBlock.id, subBlock)
}
const segments = resolveCanvasSentence(
config,
card,
(subBlockId) => visibleSubBlocksById.has(subBlockId),
(subBlockId) => onCardById.get(subBlockId) ?? null
)
if (!segments) return null
const lines = estimateSentenceLines(
segments,
(subBlockId) => getDisplayValue(subBlockState[subBlockId]?.value),
SENTENCE_WRAP_WIDTH_PX
)
return { segments, visibleSubBlocksById, lines }
}, [
type,
config,
chipBlocks,
subBlockRows,
subBlockState,
displayableSubBlocks,
displayTriggerMode,
])
/**
* Whether anything renders below the header — the summary sentence, subblock
* rows, chips, or the condition/router branch rows. The sentence counts on
* its own: one built purely from literals and fallbacks resolves no field, so
* it contributes no rows or chips, but it still paints.
*/
const hasContentBelowHeader =
sentenceData !== null ||
subBlockRows.length > 0 ||
chipBlocks.length > 0 ||
conditionRows.length > 0 ||
routerRows.length > 0 ||
showsErrorRow
/**
* Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
@@ -803,11 +1055,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
calculateDimensions: () => {
return calculateWorkflowBlockDimensions({
blockType: type,
category: config.category,
displayTriggerMode,
visibleSubBlockCount: totalRenderedRowCount,
visibleSubBlockCount: sentenceData ? 0 : totalRenderedRowCount,
conditionRowCount: conditionRows.length,
routerRowCount: routerRows.length,
chipCount: sentenceData ? 0 : chipBlocks.length,
sentenceLineCount: sentenceData?.lines ?? 0,
hasErrorRow: showsErrorRow,
})
},
dependencies: [
@@ -817,7 +1070,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
totalRenderedRowCount,
conditionRows.length,
routerRows.length,
chipBlocks.length,
sentenceData?.lines ?? 0,
Boolean(sentenceData),
horizontalHandles,
showsErrorRow,
],
})
@@ -839,8 +1096,68 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const wouldCreateConnectionCycle = (source: string, target: string) =>
wouldCreateCycle(useWorkflowStore.getState().edges, source, target)
const getCanvasRowTitle = (subBlock: SubBlockConfig) =>
subBlock.id === canvasPresentation.operationSubBlockId &&
!canvasPresentation.titleShowsOperation
? (canvasPresentation.operationRowTitle ?? subBlock.title ?? subBlock.id)
: (subBlock.title ?? subBlock.id)
const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined
const isBranchBlock = type === 'condition' || type === 'router_v2'
const sentence = sentenceData ? (
<CanvasSentenceView
segments={sentenceData.segments}
renderChip={(subBlockId) => {
const subBlock = sentenceData.visibleSubBlocksById.get(subBlockId)
if (!subBlock) return null
const rawValue = subBlockState[subBlockId]?.value
return (
<SubBlockRow
title={getCanvasRowTitle(subBlock)}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
displayAdvancedOptions={effectiveAdvanced}
canonicalIndex={canonicalIndex}
canonicalModeOverrides={canonicalModeOverrides}
variant='inline-value'
/>
)
}}
/>
) : undefined
const chips =
isBranchBlock || sentenceData || chipBlocks.length === 0 ? undefined : (
<>
{chipBlocks.map((subBlock, index) => (
<Fragment key={`statement-${subBlock.id}`}>
{index > 0 && <span className='flex-shrink-0 text-[var(--text-muted)] text-sm'>·</span>}
<SubBlockRow
title={getCanvasRowTitle(subBlock)}
value={getDisplayValue(subBlockState[subBlock.id]?.value)}
subBlock={subBlock}
rawValue={subBlockState[subBlock.id]?.value}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
displayAdvancedOptions={effectiveAdvanced}
canonicalIndex={canonicalIndex}
canonicalModeOverrides={canonicalModeOverrides}
variant={subBlock.id === 'operation' ? 'statement-primary' : 'statement-muted'}
/>
</Fragment>
))}
</>
)
const rows =
type === 'condition' || type === 'router_v2' ? null : (
<>
@@ -848,29 +1165,25 @@ export const WorkflowBlock = memo(function WorkflowBlock({
row.flatMap((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
if (subBlock.type === 'mcp-dynamic-args') {
const schema = subBlockState._toolSchema?.value as
| { properties?: Record<string, unknown> }
| undefined
const properties = schema?.properties
if (properties && typeof properties === 'object') {
const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record<
string,
unknown
>
return Object.keys(properties).map((paramName) => (
const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record<
string,
unknown
>
return getDisplayableMcpParamNames(subBlockState._toolSchema?.value, rawValue).map(
(paramName) => (
<SubBlockRow
key={`${subBlock.id}-${paramName}-${rowIndex}`}
title={formatParameterLabel(paramName)}
value={getDisplayValue(args[paramName])}
/>
))
}
return []
)
)
}
const metaIcon = getMetaIcon(subBlock)
return [
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
title={getCanvasRowTitle(subBlock)}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
@@ -881,6 +1194,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
displayAdvancedOptions={effectiveAdvanced}
canonicalIndex={canonicalIndex}
canonicalModeOverrides={canonicalModeOverrides}
variant={metaIcon ? 'meta' : 'row'}
icon={metaIcon ?? undefined}
/>,
]
})
@@ -892,17 +1207,22 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<WorkflowBlockView
id={id}
type={type}
name={name}
name={canvasPresentation.title}
isPending={isPending}
isEnabled={isEnabled}
isLocked={isLocked}
hasRing={hasRing}
ringStyles={ringStyles}
runPathStatus={runPathStatus}
isRunning={isExecuting}
isWorkflowRunning={isWorkflowRunning}
isExecutionHighlighted={isExecutionHighlighted}
Icon={config.icon}
iconBgColor={config.bgColor}
isIntegration={config.category === 'tools'}
horizontalHandles={horizontalHandles}
shouldShowDefaultHandles={shouldShowDefaultHandles}
blockHeight={blockHeight}
hasContentBelowHeader={hasContentBelowHeader}
conditionRows={conditionRows}
routerRows={routerRows}
@@ -946,10 +1266,26 @@ export const WorkflowBlock = memo(function WorkflowBlock({
contentRef={contentRef}
actionBar={
!data.isPreview && !data.isEmbedded ? (
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />
<ActionBar
blockId={id}
blockType={type}
disabled={!canEditWorkflow}
variant='swell'
isRunning={isExecuting}
isWorkflowRunning={isWorkflowRunning}
/>
) : undefined
}
rows={rows}
chips={chips}
typeLabel={canvasPresentation.typeLabel}
sentence={sentence}
hasErrorConnection={hasErrorConnection}
errorOutputEnabled={errorOutputEnabled}
onToggleErrorOutput={
canEditWorkflow && data.onSetErrorOutputEnabled ? handleToggleErrorOutput : undefined
}
highlightedHandles={highlightedHandles}
/>
)
}, shouldSkipBlockRender)
@@ -93,12 +93,7 @@ export const WorkflowControls = memo(function WorkflowControls() {
onContextMenu={handleContextMenu}
>
{/* Canvas Mode Selector */}
<Popover
open={isCanvasModeOpen}
onOpenChange={setIsCanvasModeOpen}
variant='secondary'
size='sm'
>
<Popover open={isCanvasModeOpen} onOpenChange={setIsCanvasModeOpen} size='sm'>
<Tooltip.Root>
<PopoverTrigger asChild>
<div className='flex cursor-pointer items-center gap-1'>
@@ -197,9 +192,7 @@ export const WorkflowControls = memo(function WorkflowControls() {
<Popover
open={contextMenu !== null}
onOpenChange={(open) => !open && setContextMenu(null)}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
@@ -1,8 +1,13 @@
import { memo, useMemo } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { type EdgeDiffStatus, WorkflowEdgeView } from '@sim/workflow-renderer'
import type { EdgeProps } from 'reactflow'
import { type EdgeProps, useStore } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { useLastRunEdges } from '@/stores/execution'
import {
useIsBlockActive,
useIsCurrentWorkflowExecuting,
useLastRunEdges,
} from '@/stores/execution'
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
/** Extended edge props with optional handle identifiers */
@@ -28,6 +33,33 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
}))
)
const lastRunEdges = useLastRunEdges()
const isWorkflowRunning = useIsCurrentWorkflowExecuting()
const isTargetActive = useIsBlockActive(target)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const activeTab = usePanelStore((state) => state.activeTab)
/**
* Match the block ring: darken edges when an endpoint is canvas-selected or
* open in the editor panel (same `--text-secondary` as the selection ring).
*/
const isEndpointSelected = useStore(
useCallback(
(state) =>
Boolean(
state.nodeInternals.get(source)?.selected || state.nodeInternals.get(target)?.selected
),
[source, target]
)
)
const isConnectedToSelection = Boolean(
isEndpointSelected ||
(data as { isConnectedToSelection?: boolean } | undefined)?.isConnectedToSelection
)
const isConnectedToEditor =
activeTab === 'editor' &&
currentBlockId !== null &&
(currentBlockId === source || currentBlockId === target)
const shouldHighlightEdge = isConnectedToSelection || isConnectedToEditor
const previewExecutionStatus = (
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
@@ -66,6 +98,9 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
diffStatus={diffStatus}
runStatus={runStatus}
isPreviewRun={Boolean(previewExecutionStatus)}
isWorkflowRunning={isWorkflowRunning}
isTargetActive={isTargetActive}
isConnectedToSelection={shouldHighlightEdge}
/>
)
}
@@ -7,6 +7,7 @@ export { useBlockVisual } from './use-block-visual'
export { useCanvasContextMenu } from './use-canvas-context-menu'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useDynamicHandleRefresh } from './use-dynamic-handle-refresh'
export { useIsBlockInActiveExecutionHandoff } from './use-execution-handoff'
export { useNodeUtilities } from './use-node-utilities'
export { usePreventZoom } from './use-prevent-zoom'
export { useScrollManagement } from './use-scroll-management'
@@ -23,7 +23,7 @@ interface UseBlockVisualProps {
/**
* Provides visual state and interaction handlers for workflow blocks.
* Computes ring styling based on editor open state, execution, diff, deletion, and run path states.
* Computes ring styling based on editor open state, execution, diff, deletion, and pending states.
* Ring is shown only when the editor panel is open for this block, not during selection/dragging.
* In preview mode, uses isPreviewSelected for selection highlighting.
*
@@ -84,7 +84,6 @@ export function useBlockVisual({
isPending: isPreview ? false : isPending,
isDeletedBlock: isPreview ? false : isDeletedBlock,
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
isSelected: isPreview || isEmbedded ? false : isSelected,
}),
@@ -94,7 +93,6 @@ export function useBlockVisual({
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreview,
isEmbedded,
isPreviewSelected,
@@ -106,6 +104,7 @@ export function useBlockVisual({
currentWorkflow,
activeWorkflowId,
isEnabled,
isExecuting,
isLocked,
handleClick,
hasRing,
@@ -37,7 +37,6 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
id: n.id,
type: block?.type || '',
enabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
locked: block?.locked ?? false,
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { isBlockInActiveExecutionHandoff } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-execution-handoff'
const edges = [
{ id: 'source-to-target', source: 'source', target: 'target' },
{ id: 'stale-to-target', source: 'stale-source', target: 'target' },
{ id: 'source-to-idle', source: 'source', target: 'idle-target' },
]
describe('isBlockInActiveExecutionHandoff', () => {
it('highlights the active destination and the source that just delivered it', () => {
const options = {
isExecuting: true,
activeBlockIds: new Set(['target']),
lastRunEdges: new Map([['source-to-target', 'success']]),
edges,
}
expect(isBlockInActiveExecutionHandoff({ ...options, blockId: 'target' })).toBe(true)
expect(isBlockInActiveExecutionHandoff({ ...options, blockId: 'source' })).toBe(true)
})
it('does not highlight unrelated sources or previously traversed inactive paths', () => {
const options = {
isExecuting: true,
activeBlockIds: new Set(['target']),
lastRunEdges: new Map([
['source-to-target', 'success'],
['source-to-idle', 'success'],
]),
edges,
}
expect(isBlockInActiveExecutionHandoff({ ...options, blockId: 'stale-source' })).toBe(false)
expect(isBlockInActiveExecutionHandoff({ ...options, blockId: 'idle-target' })).toBe(false)
})
it('clears every handoff highlight when execution stops', () => {
expect(
isBlockInActiveExecutionHandoff({
blockId: 'target',
isExecuting: false,
activeBlockIds: new Set(['target']),
lastRunEdges: new Map([['source-to-target', 'success']]),
edges,
})
).toBe(false)
})
it('recomputes the handoff when the execution snapshot changes', () => {
expect(
isBlockInActiveExecutionHandoff({
blockId: 'source',
isExecuting: true,
activeBlockIds: new Set(['target']),
lastRunEdges: new Map([['source-to-target', 'success']]),
edges,
})
).toBe(true)
expect(
isBlockInActiveExecutionHandoff({
blockId: 'source',
isExecuting: true,
activeBlockIds: new Set(['idle-target']),
lastRunEdges: new Map(),
edges,
})
).toBe(false)
})
})
@@ -0,0 +1,116 @@
import type { Edge } from 'reactflow'
import { useExecutionStore } from '@/stores/execution'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
type HandoffEdges = ReadonlyArray<Pick<Edge, 'id' | 'source' | 'target'>>
/**
* Only edge *membership* matters here — an edge id being present means it was
* taken. The status value is deliberately `unknown` so this stays a pure
* function over the minimum it reads, testable without the execution store.
*/
type TakenEdgeIds = ReadonlyMap<string, unknown>
interface ActiveExecutionHandoffOptions {
blockId: string
isExecuting: boolean
activeBlockIds: ReadonlySet<string>
lastRunEdges: TakenEdgeIds
edges: HandoffEdges
}
interface ActiveExecutionHandoffCache {
lastRunEdges: TakenEdgeIds
edges: HandoffEdges
highlightedBlockIds: ReadonlySet<string>
}
/**
* Memoised per execution snapshot.
*
* Every card on the canvas asks this same question on every execution update,
* so the walk runs once per snapshot rather than once per card. Keyed *weakly*
* on `activeBlockIds` so an entry dies with the snapshot that produced it —
* a module-level slot would pin the last workflow's edges, active-block set and
* run-status map for the lifetime of the tab.
*/
const activeExecutionHandoffCache = new WeakMap<ReadonlySet<string>, ActiveExecutionHandoffCache>()
/** Resolves the active handoff once for every immutable execution snapshot. */
function getActiveExecutionHandoffBlockIds(
activeBlockIds: ReadonlySet<string>,
lastRunEdges: TakenEdgeIds,
edges: HandoffEdges
): ReadonlySet<string> {
const cached = activeExecutionHandoffCache.get(activeBlockIds)
if (cached && cached.lastRunEdges === lastRunEdges && cached.edges === edges) {
return cached.highlightedBlockIds
}
const highlightedBlockIds = new Set(activeBlockIds)
for (const edge of edges) {
if (lastRunEdges.has(edge.id) && activeBlockIds.has(edge.target)) {
highlightedBlockIds.add(edge.source)
}
}
activeExecutionHandoffCache.set(activeBlockIds, {
lastRunEdges,
edges,
highlightedBlockIds,
})
return highlightedBlockIds
}
/**
* Returns whether a block is participating in the handoff into an active block.
*
* The active destination always participates. A source participates only when
* its outgoing edge has been taken and currently feeds an active destination.
*/
export function isBlockInActiveExecutionHandoff({
blockId,
isExecuting,
activeBlockIds,
lastRunEdges,
edges,
}: ActiveExecutionHandoffOptions): boolean {
if (!isExecuting) return false
return getActiveExecutionHandoffBlockIds(activeBlockIds, lastRunEdges, edges).has(blockId)
}
/**
* Selects whether a block belongs to the current live execution handoff.
*
* The selector returns a boolean so unrelated execution updates do not rerender
* every block on the canvas.
*/
export function useIsBlockInActiveExecutionHandoff(blockId: string): boolean {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
return useExecutionStore((state) => {
if (!activeWorkflowId) return false
const execution = state.workflowExecutions.get(activeWorkflowId)
if (!execution) return false
/*
* Edges are read, not subscribed to. `state.edges` is a fresh array after
* ~25 store actions that change no edge at all — block-dimension
* measurement among them — so subscribing here would re-render every card
* on the canvas each time one block is measured. The topology is static for
* the duration of a run, and this selector re-runs on every execution
* update, so an edge added mid-run is picked up on the next active-block
* change. Sibling subscriptions in `workflow-block.tsx` derive a primitive
* for the same reason.
*/
return isBlockInActiveExecutionHandoff({
blockId,
isExecuting: execution.isExecuting,
activeBlockIds: execution.activeBlockIds,
lastRunEdges: execution.lastRunEdges,
edges: useWorkflowStore.getState().edges,
})
})
}
@@ -48,8 +48,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH,
height:
block.type === 'note'
? block.height
: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
@@ -9,10 +9,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
DirectUploadErrorMock,
executionStoreState,
mockCancel,
mockExecute,
mockExecuteFromBlock,
mockFetch,
mockHandleExecutionCancelledConsole,
mockHandleExecutionErrorConsole,
mockRequestJson,
mockResolveStartCandidates,
mockRunUploadStrategy,
mockSelectBestTrigger,
@@ -92,10 +95,13 @@ const {
return {
DirectUploadErrorMock,
executionStoreState,
mockCancel: vi.fn(),
mockExecute: vi.fn(),
mockExecuteFromBlock: vi.fn(),
mockFetch: vi.fn(),
mockHandleExecutionCancelledConsole: vi.fn(),
mockHandleExecutionErrorConsole: vi.fn(),
mockRequestJson: vi.fn(),
mockResolveStartCandidates: vi.fn(),
mockRunUploadStrategy: vi.fn(),
mockSelectBestTrigger: vi.fn(),
@@ -114,7 +120,7 @@ vi.mock('next/navigation', () => ({
}))
vi.mock('@/lib/api/client/request', () => ({
requestJson: vi.fn(),
requestJson: mockRequestJson,
}))
vi.mock('@/lib/api/contracts/workflows', () => ({
@@ -182,7 +188,7 @@ vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-u
}),
reconcileFinalBlockLogs: vi.fn(),
addExecutionErrorConsoleEntry: vi.fn(),
handleExecutionCancelledConsole: vi.fn(),
handleExecutionCancelledConsole: mockHandleExecutionCancelledConsole,
handleExecutionErrorConsole: mockHandleExecutionErrorConsole,
}))
@@ -218,7 +224,7 @@ vi.mock('@/hooks/use-execution-stream', () => {
execute: mockExecute,
executeFromBlock: mockExecuteFromBlock,
reconnect: vi.fn(),
cancel: vi.fn(),
cancel: mockCancel,
cancelExecute: vi.fn(),
cancelReconnect: vi.fn(),
}),
@@ -351,6 +357,65 @@ async function drainStream(value: unknown): Promise<void> {
while (!(await reader.read()).done) {}
}
describe('useWorkflowExecution cancellation', () => {
beforeEach(() => {
vi.clearAllMocks()
executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1')
mockRequestJson.mockResolvedValue({ success: true })
})
afterEach(() => {
executionStoreState.getCurrentExecutionId.mockReturnValue(null)
})
it('leaves the run intact until the server confirms, when there is one to cancel', () => {
/*
* The server's terminal event owns teardown. Tearing down here instead
* would (a) show the run as stopped even when the cancel request fails,
* while it keeps executing and billing server-side, with the execution id
* already discarded so it cannot be retried, and (b) abort the stream
* before `onExecutionCancelled` can settle the agent-stream chrome, so a
* pending thinking-flush revives a console entry nothing will settle again.
*/
const { result, unmount } = renderWorkflowExecutionHook()
act(() => {
result().handleCancelExecution()
})
expect(mockRequestJson).toHaveBeenCalledWith(
{},
expect.objectContaining({
params: { id: 'workflow-1', executionId: 'execution-1' },
})
)
expect(mockCancel).not.toHaveBeenCalled()
expect(executionStoreState.setCurrentExecutionId).not.toHaveBeenCalled()
expect(executionStoreState.setIsExecuting).not.toHaveBeenCalled()
expect(executionStoreState.setActiveBlocks).not.toHaveBeenCalled()
expect(mockHandleExecutionCancelledConsole).not.toHaveBeenCalled()
unmount()
})
it('tears down locally when there is no server execution to cancel', () => {
executionStoreState.getCurrentExecutionId.mockReturnValue(null)
const { result, unmount } = renderWorkflowExecutionHook()
act(() => {
result().handleCancelExecution()
})
expect(mockRequestJson).not.toHaveBeenCalled()
expect(mockCancel).toHaveBeenCalledWith('workflow-1')
expect(executionStoreState.setIsExecuting).toHaveBeenCalledWith('workflow-1', false)
expect(executionStoreState.setIsDebugging).toHaveBeenCalledWith('workflow-1', false)
expect(executionStoreState.setActiveBlocks).toHaveBeenCalledWith('workflow-1', expect.any(Set))
unmount()
})
})
describe('useWorkflowExecution attachment uploads', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1896,6 +1896,7 @@ export function useWorkflowExecution() {
} else {
executionStream.cancel(activeWorkflowId)
currentChatExecutionIdRef.current = null
runFromBlockOwnerRef.current = null
setIsExecuting(activeWorkflowId, false)
setIsDebugging(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils'
const IDLE_OPTIONS = {
isExecuting: false,
isEditorOpen: false,
isPending: false,
isDeletedBlock: false,
diffStatus: null,
isPreviewSelection: false,
isSelected: false,
} as const
describe('getBlockRingStyles', () => {
it('does not render an outline for execution alone', () => {
expect(getBlockRingStyles({ ...IDLE_OPTIONS, isExecuting: true })).toEqual({
hasRing: false,
ringClassName: '',
})
})
it('preserves the selection ring while a selected block executes', () => {
const result = getBlockRingStyles({
...IDLE_OPTIONS,
isExecuting: true,
isSelected: true,
})
expect(result.hasRing).toBe(true)
expect(result.ringClassName).toContain('ring-[var(--text-secondary)]')
expect(result.ringClassName).not.toContain('animate-ring-pulse')
expect(result.ringClassName).not.toContain('ring-[var(--border-success)]')
})
it('preserves non-execution status rings while idle', () => {
const result = getBlockRingStyles({
...IDLE_OPTIONS,
diffStatus: 'new',
})
expect(result.hasRing).toBe(true)
expect(result.ringClassName).toContain('ring-[var(--brand-accent)]')
})
})
@@ -2,17 +2,14 @@ import { cn } from '@sim/emcn'
export type BlockDiffStatus = 'new' | 'edited' | null | undefined
export type BlockRunPathStatus = 'success' | 'error' | undefined
interface BlockRingOptions {
/** Whether the block is executing (shows green pulsing ring) */
/** Whether the block is executing (suppresses stale status rings) */
isExecuting: boolean
/** Whether the editor panel is open for this block (shows blue ring) */
isEditorOpen: boolean
isPending: boolean
isDeletedBlock: boolean
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
isPreviewSelection?: boolean
/** Whether the block is selected via shift-click or selection box (shows blue ring) */
isSelected?: boolean
@@ -20,7 +17,7 @@ interface BlockRingOptions {
/**
* Derives visual ring visibility and class names for workflow blocks
* based on editor open state, execution, diff, deletion, and run-path states.
* based on editor open state, execution, diff, deletion, and pending states.
*/
export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
@@ -32,76 +29,42 @@ export function getBlockRingStyles(options: BlockRingOptions): {
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreviewSelection,
isSelected,
} = options
const hasRing =
isExecuting ||
isEditorOpen ||
isSelected ||
isPending ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
!!runPathStatus
const isSelectionHighlighted = isEditorOpen || isSelected || isPreviewSelection
const hasStatusRing =
!isExecuting && (isPending || diffStatus === 'new' || diffStatus === 'edited' || isDeletedBlock)
const hasRing = Boolean(isSelectionHighlighted || hasStatusRing)
const ringClassName = cn(
// Executing block: pulsing success ring with prominent thickness (highest priority)
isExecuting && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
// Editor open, selected, or preview selection: static blue ring
!isExecuting &&
(isEditorOpen || isSelected || isPreviewSelection) &&
'ring-[1.75px] ring-[var(--brand-secondary)]',
// Editor open, selected, or preview selection: neutral highlight ring,
// matching the selection-highlighted edge color
isSelectionHighlighted && 'ring-[1.5px] ring-[var(--text-secondary)]',
// Non-active states use standard ring utilities
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPreviewSelection &&
hasRing &&
'ring-[1.75px]',
!isSelectionHighlighted && hasStatusRing && 'ring-[1.5px]',
// Pending state: warning ring
!isExecuting && !isEditorOpen && !isSelected && isPending && 'ring-[var(--warning)]',
!isSelectionHighlighted && !isExecuting && isPending && 'ring-[var(--warning)]',
// Deleted state (highest priority after active/pending)
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isSelectionHighlighted &&
!isExecuting &&
!isPending &&
isDeletedBlock &&
'ring-[var(--text-error)]',
// Diff states
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isSelectionHighlighted &&
!isExecuting &&
!isPending &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[var(--brand-accent)]',
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isSelectionHighlighted &&
!isExecuting &&
!isPending &&
!isDeletedBlock &&
diffStatus === 'edited' &&
'ring-[var(--warning)]',
// Run path states (lowest priority - only show if no other states active)
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPending &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'success' &&
'ring-[var(--border-success)]',
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPending &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'error' &&
'ring-[var(--text-error)]'
'ring-[var(--warning)]'
)
return { hasRing, ringClassName }
@@ -1,28 +1,40 @@
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, getNoteBlockHeight } from '@sim/workflow-renderer'
import { showsCanvasErrorRow } from '@/lib/workflows/blocks/canvas-rows'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getBlock } from '@/blocks/registry'
/**
* Estimates block dimensions based on block type.
* Uses subblock count to estimate height for blocks that haven't been measured yet.
* Estimates block dimensions for a block that has not been measured yet, from
* its type alone — the caller has no subblock values to work from, so the row
* count is a guess bounded to a plausible range.
*
* Routed through {@link calculateWorkflowBlockDimensions} rather than summing
* rows locally: that function owns the section gaps, the error-row height, and
* the painted floor, and a second copy of the arithmetic here drifted from it
* the moment any of those changed.
*
* @param blockType - The type of block (e.g., 'condition', 'agent')
* @returns Estimated width and height for the block
*/
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
if (blockType === 'note') {
return {
width: BLOCK_DIMENSIONS.NOTE_WIDTH,
height: getNoteBlockHeight(true),
}
}
const blockConfig = getBlock(blockType)
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
const height =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
return calculateWorkflowBlockDimensions({
blockType,
visibleSubBlockCount: estimatedRows,
hasErrorRow: blockConfig
? showsCanvasErrorRow(blockConfig, blockType, false)
: blockType !== 'starter' && blockType !== 'response',
})
}
/**
@@ -2,7 +2,10 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { isPositionalTriggerBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
import {
isPositionalTriggerBlock,
shouldHighlightContainerDropTarget,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
describe('isPositionalTriggerBlock', () => {
it('returns true for a top-level block with no incoming edges', () => {
@@ -60,3 +63,14 @@ describe('isPositionalTriggerBlock', () => {
expect(isPositionalTriggerBlock(pastedBlock, edges)).toBe(false)
})
})
describe('shouldHighlightContainerDropTarget', () => {
it('does not highlight the loop while a nested block moves within it', () => {
expect(shouldHighlightContainerDropTarget('loop-1', 'loop-1')).toBe(false)
})
it('highlights a different container as a genuine re-parent target', () => {
expect(shouldHighlightContainerDropTarget('loop-1', 'loop-2')).toBe(true)
expect(shouldHighlightContainerDropTarget(null, 'loop-1')).toBe(true)
})
})
@@ -1,9 +1,11 @@
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, getNoteBlockHeight } from '@sim/workflow-renderer'
import type { Edge, Node } from 'reactflow'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
export const SUBFLOW_DROP_TARGET_CLASS = 'subflow-node-drop-target'
/**
* Collects all descendant block IDs for container blocks (loop/parallel) in the given set.
* Used to treat a nested subflow as one unit when computing boundary edges (e.g. remove-from-subflow).
@@ -107,18 +109,31 @@ export function isPositionalTriggerBlock(
return !edges.some((edge) => edge.target === block.id)
}
/**
* Returns whether a container should be emphasized as a new drop target.
* Moving within the block's existing container is a position change, not a
* re-parent operation, so it must not activate the container highlight.
*/
export function shouldHighlightContainerDropTarget(
currentParentId: string | null | undefined,
targetContainerId: string
): boolean {
return currentParentId !== targetContainerId
}
/**
* Clears drag highlight classes and resets cursor state.
* Used when drag operations end or are cancelled.
*/
export function clearDragHighlights(): void {
document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
document.querySelectorAll(`.${SUBFLOW_DROP_TARGET_CLASS}`).forEach((el) => {
el.classList.remove(SUBFLOW_DROP_TARGET_CLASS)
})
document.body.style.cursor = ''
}
interface BlockData {
type?: string
height?: number
data?: {
parentId?: string
@@ -154,11 +169,15 @@ export function getClampedPositionForNode(
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
width:
currentBlock?.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH,
height:
currentBlock?.type === 'note'
? currentBlock.height || getNoteBlockHeight(true)
: Math.max(
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions)
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isPlainRecord } from '@sim/utils/object'
import { normalizeWorkflowEdgeSourceHandle } from '@sim/workflow-types/workflow'
import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants'
import type { SecretSafeBlockLog } from '@/lib/logs/execution/display-types'
import type { TraceSpan } from '@/lib/logs/types'
@@ -65,9 +66,10 @@ export function updateActiveBlockRefCount(
* Exclude sentinel handles here
*/
function shouldActivateEdgeClient(
handle: string | null | undefined,
rawHandle: string | null | undefined,
output: Record<string, any> | undefined
): boolean {
const handle = normalizeWorkflowEdgeSourceHandle(rawHandle)
if (!handle) return true
if (handle.startsWith('condition-')) {
@@ -1,6 +1,7 @@
import dynamic from 'next/dynamic'
import type { EdgeTypes, NodeTypes } from 'reactflow'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { ConnectionBlockSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
@@ -22,6 +23,7 @@ const NoteBlock = dynamic(
/** Custom node types for ReactFlow. */
export const nodeTypes: NodeTypes = {
connectionBlockSelector: ConnectionBlockSelector,
workflowBlock: WorkflowBlock,
noteBlock: NoteBlock,
subflowNode: SubflowNodeComponent,
@@ -38,9 +40,10 @@ export const defaultEdgeOptions = { type: 'custom' } as const
export const reactFlowStyles = [
'[&_.react-flow__handle]:!z-[30]',
'[&_.react-flow__edge-labels]:!z-[1001]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
String.raw`[&_.react-flow\_\_selection]:!border-[var(--text-secondary)]`,
String.raw`[&_.react-flow\_\_selection]:!bg-[color-mix(in_oklch,var(--text-secondary)_8%,transparent)]`,
'[&_.react-flow__background]:hidden',
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
File diff suppressed because it is too large Load Diff
@@ -36,13 +36,7 @@ export function PreviewContextMenu({
if (typeof document === 'undefined') return null
return createPortal(
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()} size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
@@ -1,10 +1,25 @@
'use client'
import { type CSSProperties, memo, useMemo } from 'react'
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
import {
CanvasSentenceView,
HANDLE_POSITIONS,
humanizeBlockName,
SubBlockRowView,
WorkflowTypeTag,
} from '@sim/workflow-renderer'
import { WORKFLOW_SOURCE_HANDLE_ID, WORKFLOW_TARGET_HANDLE_ID } from '@sim/workflow-types/workflow'
import { Handle, type NodeProps, Position } from 'reactflow'
import { resolveCanvasBlockPresentation } from '@/lib/workflows/blocks/canvas-presentation'
import {
type CardSelector,
getOperationSubBlockId,
resolveCanvasSentence,
} from '@/lib/workflows/blocks/canvas-sentence'
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
import {
getDisplayValue,
hasDisplayableRowValue,
resolveDropdownLabel,
resolveSkillsLabel,
resolveToolsLabel,
@@ -20,10 +35,10 @@ import {
isToolInputOnlySubBlock,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/variables/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { TRIGGER_REGISTRY } from '@/triggers/registry'
/** Execution status for blocks in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
@@ -39,19 +54,19 @@ interface SubBlockValueEntry {
*/
const HANDLE_STYLES = {
horizontal: '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-xs',
vertical: '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-xs',
right:
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
error:
'!z-[10] !border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
'!z-[10] !border-none !bg-[var(--text-error)] !h-[7px] !w-6 !rounded-b-[2px] !rounded-t-none',
} as const
/** Reusable style object for error handles positioned at bottom-right */
const ERROR_HANDLE_STYLE: CSSProperties = {
right: '-7px',
right: 'auto',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
bottom: '-7px',
left: 'calc(100% - 30px)',
transform: 'translateX(-50%)',
}
interface WorkflowPreviewBlockData {
@@ -68,6 +83,14 @@ interface WorkflowPreviewBlockData {
executionStatus?: ExecutionStatus
/** Subblock values from the workflow state */
subBlockValues?: Record<string, SubBlockValueEntry | unknown>
/**
* Whether the block routes its failures to a second output. The port is the
* rendered half of that choice, so it only exists when the choice was made —
* or when an edge already leaves it, which React Flow needs a mounted handle
* for whatever the flag says.
*/
errorEnabled?: boolean
hasErrorConnection?: boolean
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
}
@@ -93,23 +116,19 @@ interface SubBlockRowProps {
}
/**
* Renders a single subblock row with title and optional value.
* Matches the SubBlockRow component in WorkflowBlock.
* - Masks password fields with bullets
* - Resolves dropdown/combobox labels
* - Resolves workflow names from registry
* - Resolves variable names from store
* - Resolves tool and skill names (registry + stored names; no API access)
* - Shows '-' for other selector types that need hydration
* Resolves a subblock's value to the string the card shows.
*
* Shared by the label/value rows and the chips inside a summary sentence so a
* value cannot render one way in a row and another way inline. The preview is
* hook-free, so selector types that need an API round-trip resolve to `-`.
*/
const SubBlockRow = memo(function SubBlockRow({
title,
value,
subBlock,
rawValue,
workflowMap,
workflowLabelsReady,
}: SubBlockRowProps) {
function resolvePreviewDisplayValue(
value: string | undefined,
subBlock: SubBlockConfig | undefined,
rawValue: unknown,
workflowMap: Record<string, WorkflowMetadata>,
workflowLabelsReady: boolean
): string | undefined {
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -124,8 +143,8 @@ const SubBlockRow = memo(function SubBlockRow({
Object.values(useVariablesStore.getState().variables)
)
: null
// The preview is hook-free, so custom tools referenced only by id resolve
// through their inline schema/registry fallbacks rather than the API.
// Custom tools referenced only by id resolve through their inline
// schema/registry fallbacks rather than the API.
const toolsDisplay = resolveToolsLabel(subBlock, rawValue, [])
const skillsDisplay = resolveSkillsLabel(subBlock, rawValue, [])
const workflowName = resolveWorkflowSelectionLabel(subBlock, rawValue, workflowLookup)
@@ -144,7 +163,35 @@ const SubBlockRow = memo(function SubBlockRow({
skillsDisplay ||
workflowName ||
workflowMultiSelectionNames
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
}
/**
* Renders a single subblock row with title and optional value.
* Matches the SubBlockRow component in WorkflowBlock.
* - Masks password fields with bullets
* - Resolves dropdown/combobox labels
* - Resolves workflow names from registry
* - Resolves variable names from store
* - Resolves tool and skill names (registry + stored names; no API access)
* - Shows '-' for other selector types that need hydration
*/
const SubBlockRow = memo(function SubBlockRow({
title,
value,
subBlock,
rawValue,
workflowMap,
workflowLabelsReady,
}: SubBlockRowProps) {
const displayValue = resolvePreviewDisplayValue(
value,
subBlock,
rawValue,
workflowMap,
workflowLabelsReady
)
return (
<div className='flex h-5 items-center gap-2'>
@@ -179,11 +226,12 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
workflowMap = {},
workflowLabelsReady = false,
isTrigger = false,
horizontalHandles = false,
enabled = true,
isPreviewSelected = false,
executionStatus,
subBlockValues,
errorEnabled = false,
hasErrorConnection = false,
lightweight = false,
} = data
@@ -202,7 +250,19 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}, {})
}, [subBlockValues, lightweight])
const visibleSubBlocks = useMemo(() => {
const canvasPresentation = useMemo(
() => (blockConfig ? resolveCanvasBlockPresentation(blockConfig, name, rawValues) : undefined),
[blockConfig, name, rawValues]
)
/**
* Visible on the card, whether or not it holds a value.
*
* A sentence's core slots render either way — the chip shows the field's noun
* until it is filled — so the sentence needs this set, while the field rows
* below it need the value-bearing subset.
*/
const displayableSubBlocks = useMemo(() => {
if (!blockConfig?.subBlocks) return []
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
@@ -231,8 +291,16 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
return false
}
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
if (subBlock.condition && !evaluateSubBlockCondition(subBlock.condition, rawValues)) {
return false
}
if (
canvasPresentation?.titleShowsOperation &&
subBlock.id === canvasPresentation.operationSubBlockId
) {
return false
}
return true
})
}, [
lightweight,
@@ -243,8 +311,70 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
isTrigger,
canonicalIndex,
rawValues,
canvasPresentation,
])
/**
* The definition this operation shows, by id.
*
* Doubles as the chip lookup, which used to rescan `visibleSubBlocks` for
* every slot on every render of every previewed card.
*/
const onCardById = useMemo(() => {
const byId = new Map<string, SubBlockConfig>()
for (const subBlock of displayableSubBlocks) {
if (!byId.has(subBlock.id)) byId.set(subBlock.id, subBlock)
}
return byId
}, [displayableSubBlocks])
/* Lightweight mode has no values to test, so it keeps every displayable row. */
const visibleSubBlocks = useMemo(
() =>
lightweight
? displayableSubBlocks
: displayableSubBlocks.filter((subBlock) =>
hasDisplayableRowValue(subBlock, rawValues[subBlock.id])
),
[lightweight, displayableSubBlocks, rawValues]
)
/**
* The block's natural-language summary, which replaces its field rows.
*
* Resolved against the same visible-and-configured set the rows use, so the
* preview reaches the same sentence the editor canvas paints. Skipped in
* lightweight mode, which has no values to resolve chips from.
*/
const sentenceSegments = useMemo(() => {
const effectiveTrigger = isTrigger || type === 'starter'
if (lightweight || !blockConfig) return null
if (type === 'condition' || type === 'router_v2' || type === 'starter') return null
const availableIds = new Set(visibleSubBlocks.map((subBlock) => subBlock.id))
const operationSubBlockId = getOperationSubBlockId(blockConfig)
const card: CardSelector = effectiveTrigger
? (() => {
const triggerId = resolveSelectedTriggerId(blockConfig, rawValues)
return {
mode: 'trigger' as const,
triggerId,
triggerName: triggerId ? (TRIGGER_REGISTRY[triggerId]?.name ?? null) : null,
}
})()
: {
mode: 'action',
operationValue: operationSubBlockId ? rawValues[operationSubBlockId] : undefined,
}
return resolveCanvasSentence(
blockConfig,
card,
(subBlockId) => availableIds.has(subBlockId),
(subBlockId) => onCardById.get(subBlockId) ?? null
)
}, [lightweight, blockConfig, type, isTrigger, visibleSubBlocks, onCardById, rawValues])
/**
* Compute condition rows for condition blocks.
* In lightweight mode, returns default structure without parsing values.
@@ -320,7 +450,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
return defaultRows
}, [type, rawValues, lightweight])
if (!blockConfig) {
if (!blockConfig || !canvasPresentation) {
return null
}
@@ -330,68 +460,72 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
const shouldShowDefaultHandles = !isStarterOrTrigger && !isNoteBlock
const hasSubBlocks = visibleSubBlocks.length > 0
/*
* Gated on rows the preview actually renders. The error row that used to be
* the guaranteed content for every non-trigger block is gone, so keeping
* `shouldShowDefaultHandles` in this test painted an empty padded band under
* the header of any unconfigured block — content the editor canvas, which
* derives this from its real sections, never shows.
*/
const hasContentBelowHeader =
type === 'condition'
? conditionRows.length > 0 || shouldShowDefaultHandles
? conditionRows.length > 0
: type === 'router_v2'
? routerRows.length > 0 || shouldShowDefaultHandles
: hasSubBlocks || shouldShowDefaultHandles
? /* The Context row renders whether or not any routes are defined. */
true
: /* A sentence built only from literals resolves no field, so it
contributes no rows but still paints. */
sentenceSegments !== null || hasSubBlocks
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
return (
<div className='relative w-[250px] select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)]'>
<div className='relative w-[250px] select-none rounded-2xl border-[1.5px] border-[var(--border-1)] bg-[var(--surface-2)]'>
{/* Selection ring overlay (takes priority over execution rings) */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-secondary)]' />
<div className='pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px] ring-[var(--text-secondary)]' />
)}
{/* Success ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasSuccess && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-accent)]' />
<div className='pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px] ring-[var(--brand-accent)]' />
)}
{/* Error ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasError && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--text-error)]' />
<div className='pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px] ring-[var(--text-error)]' />
)}
{/* Target handle - not shown for triggers/starters */}
{shouldShowDefaultHandles && (
<Handle
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
id='target'
className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { top: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
position={Position.Left}
id={WORKFLOW_TARGET_HANDLE_ID}
className={HANDLE_STYLES.horizontal}
style={{ left: '-7px', top: '50%', transform: 'translateY(-50%)' }}
/>
)}
{/* Header - matches WorkflowBlock structure */}
<div
className={`flex items-center justify-between p-2 ${hasContentBelowHeader ? 'border-[var(--border-1)] border-b' : ''}`}
>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
{!isNoteBlock && (
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center overflow-hidden rounded-md [&_img]:size-full'
style={{ background: enabled ? blockConfig.bgColor : 'gray' }}
>
<IconComponent
className={`size-[16px] ${enabled ? getTileIconColorClass(blockConfig.bgColor) : 'text-[var(--text-icon)]'}`}
/>
</div>
)}
<div className='flex h-[40px] items-center justify-between px-2'>
<div className='relative z-10 flex min-w-0 flex-1 items-center'>
<span
className={`truncate text-md ${!enabled ? 'text-[var(--text-muted)]' : ''}`}
title={name}
className={`truncate text-[17px] ${!enabled ? 'text-[var(--text-muted)]' : ''}`}
title={canvasPresentation.title}
>
{name}
{humanizeBlockName(canvasPresentation.title)}
</span>
</div>
{!isNoteBlock && (
<WorkflowTypeTag
type={type}
typeLabel={canvasPresentation.typeLabel}
Icon={IconComponent}
iconBgColor={blockConfig.bgColor}
isIntegration={blockConfig.category === 'tools'}
isEnabled={enabled}
/>
)}
</div>
{/* Content area with subblocks */}
@@ -407,6 +541,33 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
workflowLabelsReady={workflowLabelsReady}
/>
))
) : sentenceSegments ? (
<CanvasSentenceView
segments={sentenceSegments}
renderChip={(subBlockId) => {
const subBlock = onCardById.get(subBlockId)
if (!subBlock) return null
const rawValue = rawValues[subBlockId]
const displayValue = resolvePreviewDisplayValue(
getDisplayValue(rawValue),
subBlock,
rawValue,
workflowMap,
workflowLabelsReady
)
/* The preview has no hooks, so a selector it cannot hydrate comes
back as the `-` sentinel. That reads as noise mid-sentence, so
hand the slot back and let its noun stand in instead. */
if (!displayValue || displayValue === '-') return null
return (
<SubBlockRowView
title={subBlock.title ?? subBlock.id}
displayValue={displayValue}
variant='inline-value'
/>
)
}}
/>
) : type === 'router_v2' ? (
<>
<SubBlockRow
@@ -432,7 +593,12 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
return (
<SubBlockRow
key={subBlock.id}
title={subBlock.title ?? subBlock.id}
title={
subBlock.id === canvasPresentation.operationSubBlockId &&
!canvasPresentation.titleShowsOperation
? (canvasPresentation.operationRowTitle ?? subBlock.title ?? subBlock.id)
: (subBlock.title ?? subBlock.id)
}
value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={lightweight ? undefined : subBlock}
rawValue={rawValue}
@@ -442,14 +608,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
)
})
)}
{/* Error row for non-trigger blocks */}
{shouldShowDefaultHandles && (
<SubBlockRow
title='error'
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
)}
</div>
)}
@@ -470,13 +628,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
@@ -498,40 +649,32 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Source and error handles for non-condition/router/note blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && !isNoteBlock && (
<>
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={horizontalHandles ? HANDLE_STYLES.right : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
{shouldShowDefaultHandles && (
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
)}
</>
<Handle
type='source'
position={Position.Right}
id={WORKFLOW_SOURCE_HANDLE_ID}
className={HANDLE_STYLES.right}
style={{ right: '-7px', top: '50%', transform: 'translateY(-50%)' }}
/>
)}
{/* The editor canvas gates this the same way — the port is the rendered
half of the error-output toggle, and a card that never opted in should
not grow one. An existing error edge keeps it mounted regardless, or
React Flow would drop that edge for having no handle to leave from. */}
{shouldShowDefaultHandles && type !== 'response' && (errorEnabled || hasErrorConnection) && (
<Handle
type='source'
position={Position.Bottom}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
)}
</div>
)
@@ -553,10 +696,11 @@ function shouldSkipPreviewBlockRender(
prevProps.data.type !== nextProps.data.type ||
prevProps.data.name !== nextProps.data.name ||
prevProps.data.isTrigger !== nextProps.data.isTrigger ||
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
prevProps.data.enabled !== nextProps.data.enabled ||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
prevProps.data.executionStatus !== nextProps.data.executionStatus ||
prevProps.data.errorEnabled !== nextProps.data.errorEnabled ||
prevProps.data.hasErrorConnection !== nextProps.data.hasErrorConnection ||
prevProps.data.lightweight !== nextProps.data.lightweight
) {
return false
@@ -1,11 +1,8 @@
'use client'
import { memo } from 'react'
import { Badge, cn } from '@sim/emcn'
import { Repeat, Split } from '@sim/emcn/icons'
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
import { Handle, type NodeProps, Position } from 'reactflow'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { SubflowNodeView } from '@sim/workflow-renderer'
import type { NodeProps } from 'reactflow'
/** Execution status for subflows in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
@@ -30,122 +27,19 @@ interface WorkflowPreviewSubflowData {
* Renders loop/parallel containers without hooks, store subscriptions,
* or interactive features.
*/
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const {
name,
width = 500,
height = 300,
kind,
enabled = true,
isPreviewSelected = false,
executionStatus,
} = data
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? Repeat : Split
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
function WorkflowPreviewSubflowInner({ data, id }: NodeProps<WorkflowPreviewSubflowData>) {
return (
<div
className='relative select-none rounded-lg border border-[var(--border-1)]'
style={{
width,
height,
}}
>
{/* Selection ring overlay (takes priority over execution rings) */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Success ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasSuccess && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-accent)]' />
)}
{/* Error ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasError && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--text-error)]' />
)}
{/* Target handle on left (input to the subflow) */}
<Handle
type='target'
position={Position.Left}
id='target'
className={leftHandleClass}
style={{
left: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
{/* Header - matches actual subflow header structure */}
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2'>
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
style={{ backgroundColor: enabled ? blockIconBg : 'var(--surface-4)' }}
>
<BlockIcon
className={cn(
'size-[16px]',
enabled ? getTileIconColorClass(blockIconBg) : 'text-[var(--text-icon)]'
)}
/>
</div>
<span
className={cn('truncate text-md', !enabled && 'text-[var(--text-muted)]')}
title={blockName}
>
{blockName}
</span>
</div>
{!enabled && <Badge variant='gray-secondary'>disabled</Badge>}
</div>
{/* Content area - matches workflow structure */}
<div
className='h-[calc(100%-50px)] pt-4 pr-[80px] pb-4 pl-4'
style={{ position: 'relative' }}
>
{/* Subflow Start - connects to first block in subflow */}
<div className='absolute top-4 left-[16px] flex items-center justify-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] px-3 py-1.5'>
<span className='text-[var(--text-primary)] text-sm'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={rightHandleClass}
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
/>
</div>
</div>
{/* End source handle on right (output from the subflow) */}
<Handle
type='source'
position={Position.Right}
id={endHandleId}
className={rightHandleClass}
style={{
right: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
<SubflowNodeView
id={id}
data={{ ...data, isPreview: true }}
selected={false}
isEnabled={data.enabled ?? true}
isLocked={false}
isFocused={false}
nestingLevel={0}
canEditWorkflow={false}
onSelect={() => undefined}
/>
)
}
@@ -15,7 +15,15 @@ import 'reactflow/dist/style.css'
import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
BLOCK_DIMENSIONS,
BLOCK_Z_BASE,
CONTAINER_CHILD_Z_BASE,
CONTAINER_DIMENSIONS,
EDGE_Z_BASE,
EDGE_Z_MAX,
} from '@sim/workflow-renderer'
import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
@@ -363,10 +371,28 @@ export function PreviewWorkflow({
}
}, [workflowState.edges, isValidWorkflowState])
/**
* Blocks that already route failures somewhere, as a content key.
*
* Such a block keeps its error port whatever its own flag says — React Flow
* drops an edge whose handle never mounts. A string rather than a Set so a
* re-received edges array with the same content does not rebuild every node.
* The raw handle is safe to test: `normalizeWorkflowEdgeHandles` only rewrites
* the side-anchored source ids, never `error`.
*/
const errorSourceBlockKey = useMemo(() => {
const ids = new Set<string>()
for (const edge of workflowState.edges ?? []) {
if (edge.sourceHandle === 'error') ids.add(edge.source)
}
return [...ids].sort().join(',')
}, [workflowState.edges])
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
const nodeArray: Node[] = []
const blocksWithErrorEdge = new Set(errorSourceBlockKey ? errorSourceBlockKey.split(',') : [])
const sortedBlocks = Object.entries(workflowState.blocks || {}).sort(
([, left], [, right]) =>
@@ -404,7 +430,6 @@ export function PreviewWorkflow({
extent: block.data?.extent || undefined,
draggable: false,
zIndex: nestingDepth,
className: parentId ? 'nested-subflow-node' : undefined,
data: {
...block.data,
name: block.name,
@@ -447,7 +472,7 @@ export function PreviewWorkflow({
parentId,
extent: block.data?.extent || undefined,
draggable: false,
zIndex: parentId ? 1000 : undefined,
zIndex: parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE,
data: {
type: block.type,
name: block.name,
@@ -459,6 +484,8 @@ export function PreviewWorkflow({
isPreviewSelected: isSelected,
executionStatus,
subBlockValues: block.subBlocks,
errorEnabled: block.errorEnabled === true,
hasErrorConnection: blocksWithErrorEdge.has(blockId),
lightweight,
},
})
@@ -476,6 +503,7 @@ export function PreviewWorkflow({
getSubflowExecutionStatus,
workflowMap,
workflowLabelsReady,
errorSourceBlockKey,
lightweight,
])
@@ -529,7 +557,14 @@ export function PreviewWorkflow({
}
}
return (workflowState.edges || []).map((edge) => {
/*
* Deployment versions and run snapshots are raw jsonb blobs that never
* pass through `loadWorkflowFromNormalizedTables`, so unlike the editor
* canvas their handles arrive un-canonicalized. React Flow drops an edge
* whose handle matches no mounted handle, so without this the preview
* renders the cards with no lines between them.
*/
return normalizeWorkflowEdgeHandles(workflowState.edges).map((edge) => {
const status = getEdgeExecutionStatus(edge)
const isErrorEdge = edge.sourceHandle === 'error'
return {
@@ -542,7 +577,11 @@ export function PreviewWorkflow({
...(status ? { executionStatus: status } : {}),
sourceHandle: edge.sourceHandle,
},
zIndex: status === 'success' ? 10 : isErrorEdge ? 5 : 0,
/* Inside the shared edge band, so a line clears the opaque container it
crosses and still passes behind cards. Execution status orders edges
within the band: a successful path draws over an error one, which
draws over an unexecuted one. */
zIndex: status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE,
}
})
}, [
@@ -4,6 +4,7 @@ import type { ComponentType } from 'react'
import { memo } from 'react'
import { cn } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { WorkflowTypeIcon } from '@sim/workflow-renderer'
import { Command } from 'cmdk'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
@@ -16,23 +17,28 @@ export const MemoizedCommandItem = memo(
icon: Icon,
bgColor,
showColoredIcon,
workflowType,
label,
}: CommandItemProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? `size-[10px] ${getTileIconColorClass(bgColor)}`
: 'size-[16px] text-[var(--text-icon)]'
)}
/>
</div>
{workflowType ? (
<WorkflowTypeIcon type={workflowType} Icon={Icon} />
) : (
<div
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? `size-[10px] ${getTileIconColorClass(bgColor)}`
: 'size-[16px] text-[var(--text-icon)]'
)}
/>
</div>
)}
<span className='truncate text-[var(--text-body)]'>{label}</span>
</Command.Item>
)
@@ -42,6 +48,7 @@ export const MemoizedCommandItem = memo(
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.workflowType === next.workflowType &&
prev.label === next.label
)
@@ -58,13 +58,15 @@ export const ActionsGroup = memo(function ActionsGroup({
export const BlocksGroup = memo(function BlocksGroup({
items,
onSelect,
heading = 'Blocks',
}: {
items: SearchBlockItem[]
onSelect: (block: SearchBlockItem) => void
heading?: string | null
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Blocks' className={GROUP_HEADING_CLASSNAME}>
<Command.Group heading={heading ?? undefined} className={GROUP_HEADING_CLASSNAME}>
{items.map((block) => (
<MemoizedCommandItem
key={block.id}
@@ -73,6 +75,7 @@ export const BlocksGroup = memo(function BlocksGroup({
icon={block.icon}
bgColor={block.bgColor}
showColoredIcon
workflowType={block.type}
label={block.name}
/>
))}
@@ -636,7 +636,12 @@ export function SearchModal({
const filteredToolOps = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(toolOperations, (op) => op.searchValue, deferredSearch)
return filterAndCap(
toolOperations,
(op) => op.name,
deferredSearch,
(op) => op.searchValue
)
}, [isOnWorkflowPage, toolOperations, deferredSearch])
const filteredDocs = useMemo(() => {
@@ -275,6 +275,17 @@ describe('filterAndSort — name ranked above secondary text', () => {
expect(filterAndSort(items, toName, 'gpt-4o', toExtra)).toHaveLength(1)
})
it('ranks a visible operation-name match above a service metadata match', () => {
const items: Item[] = [
{ name: 'List Calls', searchValue: 'AgentPhone List Calls' },
{ name: 'Agent Status', searchValue: 'Example Agent Status' },
]
const sorted = filterAndSort(items, toName, 'agent', toExtra)
expect(sorted.map((item) => item.name)).toEqual(['Agent Status', 'List Calls'])
})
it('is byte-identical to single-field ranking when no secondary accessor is given', () => {
const items = ['Slack message', 'Send message to Slack']
expect(filterAndSort(items, (s) => s, 'slack')).toEqual(
@@ -90,6 +90,12 @@ export interface CommandItemProps {
icon: ComponentType<{ className?: string }>
bgColor: string
showColoredIcon?: boolean
/**
* Core workflow block type. Renders as the shared accent chip only when the
* type has a mapped accent; unmapped types — every integration block — fall
* back to their catalog `bgColor` tile.
*/
workflowType?: string
/** Primary text of the row. */
label: string
}
@@ -52,13 +52,7 @@ export function EmptyAreaContextMenu({
disableCreateFolder = false,
}: EmptyAreaContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()} size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
@@ -80,7 +80,7 @@ const NO_PINS: ReadonlySet<string> = new Set()
let container: HTMLDivElement
let root: Root
function render() {
function render(overrides: Partial<Parameters<typeof WorkspaceHeader>[0]> = {}) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
@@ -105,6 +105,7 @@ function render() {
onUploadLogo={() => {}}
onLeaveWorkspace={async () => {}}
isLeavingWorkspace={false}
{...overrides}
/>
)
})
@@ -154,6 +155,21 @@ afterEach(() => {
})
describe('WorkspaceHeader workspace switcher highlight', () => {
it('shows the route workspace identity while the switcher list is unavailable', () => {
render({
activeWorkspace: { name: 'Brightwave' },
workspaceId: 'ws-brightwave',
workspaces: [],
isWorkspaceMenuOpen: false,
})
const switcher = container.querySelector('button[aria-label="Switch workspace"]')
expect(switcher).toBeDisabled()
expect(switcher).toHaveTextContent('Brightwave')
expect(switcher).toHaveTextContent('B')
expect(switcher?.querySelector('.animate-pulse')).toBeNull()
})
it('leaves Enter unarmed until a cursor is on screen', () => {
render()
@@ -445,31 +445,35 @@ function WorkspaceHeaderImpl({
className={chipVariants({ fullWidth: true })}
>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
{!activeWorkspaceFull ? (
<Skeleton className='size-[16px] rounded-sm' />
) : (
{activeWorkspaceFull?.logoUrl ? (
<>
{activeWorkspaceFull.logoUrl ? (
<img
src={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
className='size-[16px] rounded-sm object-cover group-hover:invisible'
/>
) : (
<div
className='flex size-[16px] items-center justify-center rounded-sm text-[9px] text-white leading-none group-hover:invisible'
style={{
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
}}
>
{workspaceInitial}
</div>
)}
<img
src={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
className='size-[16px] rounded-sm object-cover group-hover:invisible'
/>
<PanelLeft
aria-hidden
className='pointer-events-none invisible absolute inset-0 m-auto size-[16px] rotate-180 text-[var(--text-icon)] group-hover:visible'
/>
</>
) : activeWorkspace ? (
<>
<div
className='flex size-[16px] items-center justify-center rounded-sm text-[9px] text-white leading-none group-hover:invisible'
style={{
backgroundColor: activeWorkspaceFull?.color ?? 'var(--brand-accent)',
}}
>
{workspaceInitial}
</div>
<PanelLeft
aria-hidden
className='pointer-events-none invisible absolute inset-0 m-auto size-[16px] rotate-180 text-[var(--text-icon)] group-hover:visible'
/>
</>
) : (
<Skeleton className='size-[16px] rounded-sm' />
)}
</div>
</button>
@@ -852,21 +856,19 @@ function WorkspaceHeaderImpl({
title={activeWorkspace?.name}
disabled
>
{activeWorkspaceFull ? (
activeWorkspaceFull.logoUrl ? (
<img
src={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
className='size-[16px] flex-shrink-0 rounded-sm object-cover'
/>
) : (
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm text-[9px] text-white leading-none'
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
>
{workspaceInitial}
</div>
)
{activeWorkspaceFull?.logoUrl ? (
<img
src={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
className='size-[16px] flex-shrink-0 rounded-sm object-cover'
/>
) : activeWorkspace ? (
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm text-[9px] text-white leading-none'
style={{ backgroundColor: activeWorkspaceFull?.color ?? 'var(--brand-accent)' }}
>
{workspaceInitial}
</div>
) : (
<Skeleton className='size-[16px] flex-shrink-0 rounded-sm' />
)}
@@ -44,6 +44,7 @@ import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
import { captureEvent } from '@/lib/posthog/client'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
@@ -422,6 +423,7 @@ export const Sidebar = memo(function Sidebar({
const posthog = usePostHog()
const { data: sessionData, isPending: sessionLoading } = useSession()
const { workspace: routeWorkspace } = useWorkspaceHostContext()
const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext()
const {
config: permissionConfig,
@@ -1354,7 +1356,7 @@ export const Sidebar = memo(function Sidebar({
)}
>
<WorkspaceHeader
activeWorkspace={activeWorkspace}
activeWorkspace={activeWorkspace ?? routeWorkspace}
workspaceId={workspaceId}
workspaces={workspaces}
pinnedWorkspaceIds={pinnedWorkspaceIds}
+290
View File
@@ -11,3 +11,293 @@ These rules apply to block definitions under `apps/sim/blocks/**`.
- When supporting file inputs, follow the basic/advanced pattern and normalize with `normalizeFileInput`.
- Keep block outputs aligned with what the referenced tools actually return.
- `{Service}BlockMeta.skills` (curated, one-click-add agent skills shown on the integration detail page) must be grounded in operations the block exposes via `tools.access` and derived from real, popular use cases found online — web-search and source each one; never invent or hallucinate skills.
## Canvas sentences
A block's card can replace its label/value rows with one line of prose:
```
Slack ← header (already names the block)
Post ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips
```
Declare it under `canvasPresentation.sentences` — `default` for a block with no
operation dropdown, `byOperation` keyed by the dropdown's **option ids** otherwise.
A block that declares nothing keeps the row layout, so adoption is incremental.
Validate with `bun run apps/sim/scripts/check-canvas-sentences.ts --block=<type>`.
Everything below that is mechanically checkable is enforced there — a sentence
that breaks a rule fails **silently** at runtime (no throw, no log, just a card
that stops reading like the rest of the canvas), which is why the check exists.
### `core`: what an untouched card says
Every clause is optional by default and renders only once its field is filled.
`core: true` is the one exception: that clause **always** renders, showing the
value when set and the field's **noun** when not.
```
Post ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← configured
Post ⟨a message⟩ ← untouched, from `core` alone
```
The noun is derived from the subblock's `title` (`resolveFieldNoun`), so it costs
nothing to author — and **it carries its own article**, which is why you never put
`a`/`an`/`the` in front of a core chip. Where a title cannot be read mid-sentence
("Message ID to Reply To"), set `canvasNoun` on the subblock rather than working
around it in the copy.
Two rules follow, both enforced:
- **The core clauses alone must read as a complete sentence** — they are the whole
of what a fresh card says. Every sentence needs at least one core clause or some
literal copy; the check runs the real resolver against an empty card and fails
if nothing comes back.
- **A core clause's field must be provably on the card for that operation.** There
is no replacement copy in this DSL, so a clause gated away silently drops and
the promise `core` makes was false. Anchor on a field the operation always
shows, or drop `core` and let literal copy carry the sentence.
Do not mark every clause `core` — a configured card would become a wall of
placeholders. Mark what the sentence is *about*; leave the details optional.
#### `mode: 'advanced'` fields can never be core
A card opens in **basic** mode, so a standalone `mode: 'advanced'` field is not on
it. `core` on one is a promise the card cannot keep: the clause drops and, since a
fresh card has no filled rows either, the block paints a bare header. This is the
single most common way to get it wrong — 35 sentences shipped it — and it is now
caught by `check:canvas-sentences`.
It bites hardest on **list operations**, whose only fields are usually advanced
pagination controls. Those want literal copy, not an anchor:
```ts
// ✗ `limit` is mode:'advanced' — a fresh card renders nothing at all
list_customers: [{ text: 'List customers, up to', field: 'limit', core: true }]
// ✓ the literal always renders; the detail appears once it is filled
list_customers: ['List customers', { text: ', up to', field: 'limit' }]
```
A canonical basic/advanced **pair** is fine to anchor on — one member is always
showing — provided the clause names every member (`field: ['tableSelector',
'manualTableId']`), which is separately enforced.
#### An operation whose empty state means "act on all"
Some operations act on one record with an id and on *everything* without one —
their subblock usually says so ("Leave empty to list all contacts"). Marking the
id `core` makes an untouched card assert a single target it will not act on. Write
literal copy that is true both ways:
```ts
// ✗ claims one contact; untouched, this returns every contact
get_contacts: [{ text: 'Read contact', field: 'contactId', core: true }]
// ✓ true empty and true filled
get_contacts: ['Read contacts', { text: 'matching', field: 'contactId' }]
```
#### Give the operation dropdown a default
A block whose `operation` dropdown has no `value: () => '<id>'` stores `null` on
creation, so no `byOperation` entry matches and the card paints nothing until the
user opens the panel. Seed it with the operation a user most likely wants — a read,
not a mutation. Use `value:`, not `defaultValue:` — the panel's dropdown only
reads `value`, so the two would disagree.
### Trigger cards
A card in trigger mode paints its own sentence, from `canvasPresentation.triggerSentences`:
```ts
triggerSentences?: {
default?: CanvasSentence
byTrigger?: Record<string, CanvasSentence> // keyed by trigger id
}
```
**Most blocks declare nothing.** Trigger mode usually exposes a webhook URL and a
signing secret — plumbing, not meaning — and the thing worth reading is *which*
event was picked. That is already curated as the trigger's `name` in
`apps/sim/triggers/`, so the card derives `Run on Pull Request Opened` with no
authoring at all. Declare `triggerSentences` only when the configuration is
itself the meaning, as Schedule's frequency is.
What the sentence *says* shifts with the mode, though the voice does not. An
action sentence names what the block does; a trigger sentence names what starts
the run — `Run on an email arriving in ⟨INBOX⟩`, never `Read an email`. It is a
separate slot rather than another `byOperation` key because trigger mode swaps
the subblock set wholesale, and the operation dropdown still holds its
action-mode default.
Sim's own entry points (`start_trigger`, `manual_trigger`, `chat_trigger`,
`api_trigger`, `input_trigger`, `starter`) are exempt: they *are* the start of
the run and their header already says so, so a sentence would only repeat it.
### Voice
- **Imperative, the card naming its own action.** `Post a message to ⟨#eng⟩`. Never
`Posts a message` (third person), never `Post a Slack message` — the header
already says Slack, so naming it again wastes the line. The heading above the
sentence is the operation title (`Send Email`), and the two share a voice.
`check:canvas-sentences` enforces this on action *and* trigger cards.
- **The sentence carries the verb.** It replaces the chips row, which is what
shows the operation today. `List channels`, not `Channels`.
- **Lead with the verb, put the value last** where possible — the chip is the part
a user scans for.
- **One clause per fact, most important first.** The card is 250px wide and
wraps at ~2 lines; later clauses are the ones that fall off.
- **At most three value chips**, and two reads best — every chip adds width and
pushes the card taller. An operation with no field worth showing is just a
literal: `['List all channels']` is a complete, valid sentence.
- Sentence case, no trailing period, no articles you can drop (`Read schema of`
beats `Read the schema of the table`).
### Structure
- **Mark the clauses the sentence is *about* `core`**, and leave the rest optional.
A sentence may have as many core clauses as it needs to read whole — they hold
only their own slot, so there is no one-anchor limit.
- **Use literal copy when every field is an optional filter.** A list/search
operation whose filters are all optional has nothing worth a placeholder. Lead
with a literal and let every clause be optional:
`['List issues', { text: ', assigned to', field: 'assignee' }]`.
- **Each optional clause owns its leading connective.** Write
`{ text: ', where', field: 'filter' }`, never
`{ ..., after: ', where' }, { field: 'filter' }` — a dropped clause takes its own
`text` with it, but an `after` on the *previous* clause survives and dangles.
- **Bare strings are always-on literal copy.** Prefer folding them into a clause's
`text` so they can drop with the value.
- **A chip is a noun, never the verb.** A chip renders a dropdown's *label*, so
when those labels are themselves verbs (`Archive` / `Unarchive`) you cannot
build the sentence around one — `Apply ⟨Archive⟩ to thread ⟨id⟩` is the
result. State the change instead: `Set thread ⟨id⟩ to ⟨Archive⟩`.
### The four mistakes that break cards silently
1. **Naming one member of a canonical pair.** A `canonicalParamId` group has a
basic picker and an advanced raw-id input; an advanced-mode user has only the
second filled. List every member:
`field: ['tableSelector', 'manualTableId']`. Three naming conventions are in
use (`xSelector`/`x`, `xSelector`/`manualX`, `uploadX`/`xRef`), so take the
membership from the spec's `canonicalGroups` — you cannot derive it from the
canonical id.
A `field` array is also the right tool for **mutually exclusive alternates
that are not a canonical pair** — Slack's destination is a channel *or* a DM
id, Sendblue's is a number list *or* a group id. First available wins.
2. **Referencing a field the operation never shows.** A subblock gated
`condition: { field: 'operation', value: [...] }` can only appear in the
`byOperation` entries it lists.
3. **Two operations that render the same prose.** On a block with dozens of
similar read endpoints it is easy to paste a clause and never adjust it, so
`get_thread` and `get_thread_replies` both say `Read thread ⟨id⟩` and the
card cannot tell the user which one runs. Name what each actually returns.
The check runs on **both** readings, so two operations may not collide once
their optional clauses are empty either — which is how an untouched card
reads. `Add tag ⟨id⟩ to contact ⟨x⟩` and `… to conversation ⟨y⟩` both bare
to `Add tag ⟨id⟩`; marking the target clause `core` is what tells them apart.
4. **An optional clause carrying the action's target or scope.** This one the
validator cannot catch — 895 clauses fleet-wide fit the shape and only ~45
are defects, so the discriminator is semantic. Ask what the sentence means
with that clause gone. `Remove user ⟨id⟩` reads as deleting the user, not
removing them from a group; `Delete records from ⟨index⟩` reads as clearing
the whole index. If the truncated reading names a broader or different
action, mark that clause `core` so it holds its place. `Delete thread ⟨id⟩`
losing an optional `, in ⟨channel⟩` is fine — it still means the same thing.
### Two rules the validator enforces on your copy
- **No article in front of a chip.** For a `core` chip the noun already supplies
one, so `Create a ⟨campaignType⟩` reads `Create a a campaign type` — drop the
article and let the noun carry it. For an optional chip the article has to agree
with a dropdown *label* whose initial sound you cannot know
(`Create a ⟨A/B Split⟩ campaign`), so move the value out from behind it:
`Create a campaign of type ⟨type⟩`.
- **Both halves of a correlative, or neither.** A clause opening `from …` or
`between …` needs the clause carrying `to …`/`and …` to survive an empty card,
or `Route from ⟨an origin⟩` describes a different journey. Mark both halves
`core`.
### Versioned blocks
28 files export a `_v2` (or `_v3`…) variant that spreads the base — `export const
GmailV2Block = { ...GmailBlock, type: 'gmail_v2', ... }`. The spread carries
`canvasPresentation` with it, which is right only when the variant has the *same*
operation dropdown.
**When the operation sets differ, inheritance is a bug.** `file.ts` chains
`FileV5 → FileV4 → FileV3`, and an inherited `byOperation` carries keys like
`file_parser_v3` that later dropdowns do not have — the validator reports
`no option with id`. Give every export whose dropdown differs its own
`canvasPresentation`.
Always run `--block=<type>` for *every* registry type the file exports, not just
the base. That is what tells you which case you are in.
### Worked example
```ts
canvasPresentation: {
defaultTitle: 'Table',
sentences: {
byOperation: {
query_rows: [
{ text: 'Query rows from', field: ['tableSelector', 'manualTableId'], core: true },
{ text: ', where', field: ['filterBuilder', 'filter'] },
{ text: ', up to', field: 'limit', after: 'rows' },
],
get_schema: [
{ text: 'Read the schema of', field: ['tableSelector', 'manualTableId'], core: true },
],
},
},
}
```
Renders as `Query rows from ⟨orders⟩, where ⟨status = open⟩, up to ⟨100⟩ rows`,
shortens to `Query rows from ⟨orders⟩` when only the table is set, and reads
`Query rows from ⟨a table⟩` on an untouched card.
### Other things that bite
- **`defaultTitle` is required** by the `canvasPresentation` type, so declaring
sentences also decides the card's header for auto-named instances. Use the
block's name minus any "(Legacy)" suffix. Do not add `typeLabel` or
`operationSubBlockId` as part of a sentence change — those alter title
resolution for workflows that already exist.
- **Trigger mode has no sentence.** A dual-mode block's card keeps its rows when
used as a trigger, so write only for the action side.
### Context to write from
Start with the spec tool — it resolves everything a sentence depends on, so you
do not have to reconstruct it from a 500–3600 line block file:
```bash
bun run apps/sim/scripts/canvas-sentence-spec.ts --block=slack --pretty
```
It reports, per operation, the label, what the underlying tool `does`, and
`fields` — **the only subblock ids that operation can show**, already resolved
through every `condition`. Plus `canonicalGroups` with every pair fully
enumerated. Naming a field outside an operation's `fields` list is the dead-clause
bug; listing one member of a canonical group is the advanced-mode bug. The spec
makes both mechanical.
When `hasCatalogEntry` is `false` the block is `category: 'blocks'` and has no
row in `integrations.json` — read `apps/sim/tools/{name}/*.ts` for each
operation's description instead. The `enrichment` block is the one exception:
its per-operation prose lives on the `EnrichmentConfig` `description` under
`apps/sim/enrichments/{id}/{id}.ts`.
The spec already omits credentials, passwords, and the operation selector, so
anything it lists is fair game for a chip. Dropdowns that carry a default are
still a judgement call — `level` defaulting to `All` spends a chip to say
nothing.
For intent a tool description does not settle, `apps/docs/content/docs/en/integrations/{service}.mdx`
carries hand-written `MANUAL-CONTENT-START:intro` prose.
+24
View File
@@ -2,6 +2,9 @@ import { A2AIcon } from '@/components/icons'
import { type BlockConfig, IntegrationType } from '@/blocks/types'
import { normalizeFileInput, parseOptionalNumberInput } from '@/blocks/utils'
/** Attachments, whichever mode the card is in. */
const FILES_FIELD = ['fileUpload', 'fileReference'] as const
export const A2ABlock: BlockConfig = {
type: 'a2a',
name: 'A2A',
@@ -15,6 +18,27 @@ export const A2ABlock: BlockConfig = {
integrationType: IntegrationType.DevOps,
bgColor: '#4151B5',
icon: A2AIcon,
canvasPresentation: {
defaultTitle: 'A2A',
sentences: {
byOperation: {
a2a_send_message: [
{ text: 'Send', field: 'message', core: true },
{ text: 'to the agent at', field: 'agentUrl', core: true },
{ text: ', attaching', field: FILES_FIELD },
],
a2a_get_task: [
{ text: 'Read the state of task', field: 'taskId', core: true },
{ text: 'on the agent at', field: 'agentUrl' },
],
a2a_cancel_task: [
{ text: 'Cancel task', field: 'taskId', core: true },
{ text: 'on the agent at', field: 'agentUrl' },
],
a2a_get_agent_card: [{ text: 'Read the agent card at', field: 'agentUrl', core: true }],
},
},
},
subBlocks: [
{
+10
View File
@@ -86,6 +86,16 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
integrationType: IntegrationType.AI,
bgColor: 'var(--brand)',
icon: AgentIcon,
canvasPresentation: {
defaultTitle: 'Agent',
sentences: {
default: [
{ text: 'Prompt', field: 'model', core: true },
{ text: 'with', field: 'messages' },
{ text: ', using', field: 'tools' },
],
},
},
subBlocks: [
{
id: 'messages',
+92
View File
@@ -14,6 +14,95 @@ export const AgentMailBlock: BlockConfig = {
bgColor: '#000000',
icon: AgentMailIcon,
authMode: AuthMode.ApiKey,
canvasPresentation: {
defaultTitle: 'AgentMail',
sentences: {
byOperation: {
send_message: [
{ text: 'Send', field: 'subject', core: true },
{ text: 'to', field: 'to', core: true },
],
reply_message: [
{ text: 'Reply to message', field: 'replyMessageId', core: true },
{ text: ', from inbox', field: 'inboxId' },
],
forward_message: [
{ text: 'Forward message', field: 'forwardMessageId', core: true },
{ text: 'to', field: 'to' },
],
list_threads: [
{ text: 'List threads in inbox', field: 'inboxId', core: true },
{ text: ', labeled', field: 'labels' },
{ text: ', up to', field: 'limit', after: 'threads' },
],
get_thread: [
{ text: 'Fetch thread', field: 'threadId', core: true },
{ text: 'from inbox', field: 'inboxId' },
],
update_thread: [
{ text: 'Relabel thread', field: 'threadId', core: true },
{ text: ', adding', field: 'addLabels' },
{ text: ', removing', field: 'removeLabels' },
],
delete_thread: [
{ text: 'Delete thread', field: 'threadId', core: true },
{ text: 'from inbox', field: 'inboxId' },
],
list_messages: [
{ text: 'List messages in inbox', field: 'inboxId', core: true },
{ text: ', up to', field: 'limit', after: 'messages' },
],
get_message: [
{ text: 'Fetch message', field: 'messageId', core: true },
{ text: 'from inbox', field: 'inboxId' },
],
update_message: [
{ text: 'Relabel message', field: 'updateMessageId', core: true },
{ text: ', adding', field: 'msgAddLabels' },
{ text: ', removing', field: 'msgRemoveLabels' },
],
create_draft: [
{ text: 'Draft', field: 'subject', core: true },
{ text: 'to', field: 'to' },
{ text: ', scheduled for', field: 'sendAt' },
],
list_drafts: [
{ text: 'List drafts in inbox', field: 'inboxId', core: true },
{ text: ', up to', field: 'limit', after: 'drafts' },
],
get_draft: [
{ text: 'Fetch draft', field: 'draftId', core: true },
{ text: 'from inbox', field: 'inboxId' },
],
update_draft: [
{ text: 'Update draft', field: 'draftId', core: true },
{ text: ', setting subject to', field: 'subject' },
{ text: ', scheduled for', field: 'sendAt' },
],
delete_draft: [
{ text: 'Delete draft', field: 'draftId', core: true },
{ text: 'from inbox', field: 'inboxId' },
],
send_draft: [
{ text: 'Send draft', field: 'draftId', core: true },
{ text: 'from inbox', field: 'inboxId' },
],
create_inbox: [
'Create an email inbox',
{ text: 'with username', field: 'username' },
{ text: 'on', field: 'domain' },
{ text: ', named', field: 'displayName' },
],
list_inboxes: ['List inboxes', { text: ', up to', field: 'limit', after: 'at a time' }],
get_inbox: [{ text: 'Fetch inbox', field: 'inboxIdParam', core: true }],
update_inbox: [
{ text: 'Rename inbox', field: 'inboxIdParam', core: true },
{ text: 'to', field: 'displayName' },
],
delete_inbox: [{ text: 'Delete inbox', field: 'inboxIdParam', core: true }],
},
},
},
subBlocks: [
{
@@ -106,6 +195,7 @@ export const AgentMailBlock: BlockConfig = {
{
id: 'to',
title: 'To',
canvasNoun: 'a recipient',
type: 'short-input',
placeholder: 'recipient@example.com',
condition: {
@@ -185,6 +275,7 @@ export const AgentMailBlock: BlockConfig = {
{
id: 'replyMessageId',
title: 'Message ID to Reply To',
canvasNoun: 'a message ID',
type: 'short-input',
placeholder: 'Message ID',
condition: { field: 'operation', value: 'reply_message' },
@@ -261,6 +352,7 @@ export const AgentMailBlock: BlockConfig = {
{
id: 'forwardMessageId',
title: 'Message ID to Forward',
canvasNoun: 'a message ID',
type: 'short-input',
placeholder: 'Message ID',
condition: { field: 'operation', value: 'forward_message' },
+86
View File
@@ -36,6 +36,90 @@ export const AgentPhoneBlock: BlockConfig = {
bgColor: 'linear-gradient(135deg, #1a1a1a 0%, #0a2a14 100%)',
icon: AgentPhoneIcon,
authMode: AuthMode.ApiKey,
canvasPresentation: {
defaultTitle: 'AgentPhone',
sentences: {
byOperation: {
create_number: [
{
text: 'Provision a new phone number in',
field: 'country',
core: true,
},
{ text: ', with area code', field: 'areaCode' },
],
list_numbers: ['List provisioned phone numbers', { text: ', up to', field: 'limit' }],
release_number: [{ text: 'Release phone number', field: 'numberId', core: true }],
get_number_messages: [
{ text: 'Fetch messages received on number', field: 'numberId', core: true },
{ text: ', up to', field: 'messagesLimit', after: 'messages' },
],
create_call: [
{ text: 'Place a voice call to', field: 'toNumberCall', core: true },
{ text: ', opening with', field: 'initialGreeting' },
],
list_calls: [
'List calls',
{ text: ', with status', field: 'callsStatus' },
{ text: ', matching', field: 'callsSearch' },
],
get_call: [
{ text: 'Fetch call', field: 'callId', after: 'with its transcript', core: true },
],
get_call_transcript: [{ text: 'Read the transcript of call', field: 'callId', core: true }],
list_conversations: ['List conversation threads', { text: ', up to', field: 'limit' }],
get_conversation: [
{ text: 'Fetch conversation', field: 'conversationId', core: true },
{ text: 'and its latest', field: 'messageLimit', after: 'messages' },
],
update_conversation: [
{ text: 'Update conversation', field: 'conversationId', core: true },
{ text: ', setting metadata to', field: 'metadata' },
],
get_conversation_messages: [
{
text: 'Page through messages in conversation',
field: 'conversationId',
core: true,
},
{ text: ', up to', field: 'messagesLimit', after: 'messages' },
],
send_message: [
{ text: 'Send', field: 'messageBody', core: true },
{ text: 'to', field: 'toNumberMessage', core: true },
{ text: ', with media', field: 'mediaUrl' },
],
react_to_message: [
{ text: 'Add', field: 'reaction', after: 'as a tapback', core: true },
{ text: 'to message', field: 'messageId', core: true },
],
create_contact: [
{ text: 'Create contact', field: 'contactName', core: true },
{ text: 'for', field: 'contactPhoneNumber', core: true },
],
list_contacts: [
'List contacts',
{ text: ', matching', field: 'contactsSearch' },
{ text: ', up to', field: 'limit' },
],
get_contact: [{ text: 'Fetch contact', field: 'contactId', core: true }],
update_contact: [
{ text: 'Update contact', field: 'contactId', core: true },
{ text: ', setting name to', field: 'contactName' },
],
delete_contact: [{ text: 'Delete contact', field: 'contactId', core: true }],
get_usage: ['Read account usage totals'],
get_usage_daily: [
'Read daily usage totals',
{ text: 'for the last', field: 'usageDays', after: 'days' },
],
get_usage_monthly: [
'Read monthly usage totals',
{ text: 'for the last', field: 'usageMonths', after: 'months' },
],
},
},
},
subBlocks: [
{
@@ -128,6 +212,7 @@ export const AgentPhoneBlock: BlockConfig = {
{
id: 'toNumberCall',
title: 'To Phone Number',
canvasNoun: 'a phone number',
type: 'short-input',
placeholder: '+14155551234',
condition: { field: 'operation', value: 'create_call' },
@@ -281,6 +366,7 @@ export const AgentPhoneBlock: BlockConfig = {
{
id: 'toNumberMessage',
title: 'To Phone Number',
canvasNoun: 'a phone number',
type: 'short-input',
placeholder: '+14155551234',
condition: { field: 'operation', value: 'send_message' },
+79
View File
@@ -3,6 +3,13 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
/*
* Canonical basic/advanced pair for the attachment upload, shared by the card
* sentence below. Listing both members is what keeps the sentence working for
* an advanced-mode user, who has only the file reference filled.
*/
const ATTACH_FILE_FIELD = ['uploadFile', 'fileRef'] as const
export const AgiloftBlock: BlockConfig = {
type: 'agiloft',
name: 'Agiloft',
@@ -14,6 +21,78 @@ export const AgiloftBlock: BlockConfig = {
integrationType: IntegrationType.Productivity,
bgColor: '#001028',
icon: AgiloftIcon,
canvasPresentation: {
defaultTitle: 'Agiloft',
sentences: {
byOperation: {
create_record: [
{ text: 'Create a record in', field: 'table', core: true },
{ text: ', with', field: 'data' },
],
read_record: [
{ text: 'Read record', field: 'recordId', core: true },
{ text: 'from', field: 'table' },
{ text: ', returning', field: 'fields' },
],
update_record: [
{ text: 'Update record', field: 'recordId', core: true },
{ text: 'in', field: 'table' },
{ text: ', setting', field: 'data' },
],
delete_record: [
{ text: 'Delete record', field: 'recordId', core: true },
{ text: 'from', field: 'table' },
],
search_records: [
{ text: 'Search', field: 'table', core: true },
{ text: 'for', field: 'query' },
{ text: ', up to', field: 'limit', after: 'records' },
],
select_records: [
{ text: 'Select record IDs from', field: 'table', core: true },
{ text: ', where', field: 'where' },
],
saved_search: [{ text: 'List saved searches on', field: 'table', core: true }],
attach_file: [
{ text: 'Attach', field: ATTACH_FILE_FIELD, core: true },
{ text: 'to record', field: 'recordId', core: true },
{ text: ', in field', field: 'fieldName' },
],
retrieve_attachment: [
{
text: 'Download the attachment in field',
field: 'fieldName',
core: true,
},
{ text: 'on record', field: 'recordId', core: true },
],
remove_attachment: [
{
text: 'Remove the attachment in field',
field: 'fieldName',
core: true,
},
{ text: 'from record', field: 'recordId', core: true },
],
attachment_info: [
{
text: 'Read attachment details for field',
field: 'fieldName',
core: true,
},
{ text: 'on record', field: 'recordId', core: true },
],
lock_record: [
{ text: 'Run lock action', field: 'lockAction', core: true },
{ text: 'on record', field: 'recordId', core: true },
],
get_choice_line_id: [
{ text: 'Resolve the internal ID of choice', field: 'value', core: true },
{ text: 'on field', field: 'fieldName' },
],
},
},
},
authMode: AuthMode.ApiKey,
subBlocks: [
+129
View File
@@ -100,6 +100,135 @@ export const AhrefsBlock: BlockConfig<AhrefsResponse> = {
integrationType: IntegrationType.Analytics,
bgColor: '#FFFFFF',
icon: AhrefsIcon,
canvasPresentation: {
defaultTitle: 'Ahrefs',
sentences: {
byOperation: {
ahrefs_domain_rating: [
{ text: 'Read Domain Rating for', field: 'target', core: true },
{ text: ', as of', field: 'date' },
],
ahrefs_metrics: [
{ text: 'Read organic and paid search metrics for', field: 'target', core: true },
{ text: ', in', field: 'country' },
],
ahrefs_backlinks: [
{ text: 'List backlinks pointing to', field: 'target', core: true },
{ text: ', up to', field: 'limit', after: 'links' },
],
ahrefs_backlinks_stats: [
{
text: 'Read backlink and referring domain totals for',
field: 'target',
core: true,
},
{ text: ', as of', field: 'date' },
],
ahrefs_referring_domains: [
{ text: 'List domains linking to', field: 'target', core: true },
{ text: ', up to', field: 'limit', after: 'domains' },
],
ahrefs_broken_backlinks: [
{ text: 'List broken backlinks pointing to', field: 'target', core: true },
{ text: ', up to', field: 'limit', after: 'links' },
],
ahrefs_organic_keywords: [
{ text: 'List organic keywords for', field: 'target', core: true },
{ text: ', in', field: 'country' },
],
ahrefs_organic_competitors: [
{ text: 'List organic search competitors of', field: 'target', core: true },
{ text: ', in', field: 'country' },
],
ahrefs_top_pages: [
{
text: 'List top pages of',
field: 'target',
after: 'by organic traffic',
core: true,
},
{ text: ', in', field: 'country' },
],
ahrefs_paid_pages: [
{
text: 'List pages of',
field: 'target',
after: 'receiving paid traffic',
core: true,
},
{ text: ', in', field: 'country' },
],
ahrefs_anchors: [
{ text: 'Break down anchor text in backlinks to', field: 'target', core: true },
{ text: ', up to', field: 'limit', after: 'anchors' },
],
ahrefs_keyword_overview: [
{ text: 'Read volume, difficulty, and CPC for', field: 'keyword', core: true },
{ text: ', in', field: 'country' },
],
ahrefs_related_terms: [
{ text: 'Find keyword ideas related to', field: 'keyword', core: true },
{ text: ', in', field: 'country' },
],
ahrefs_domain_rating_history: [
{ text: 'Chart Domain Rating over time for', field: 'target', core: true },
{ text: ', since', field: 'dateFrom' },
],
ahrefs_metrics_history: [
{ text: 'Chart organic and paid traffic for', field: 'target', core: true },
{ text: ', since', field: 'dateFrom' },
],
ahrefs_refdomains_history: [
{ text: 'Chart referring domain counts for', field: 'target', core: true },
{ text: ', since', field: 'dateFrom' },
],
ahrefs_keywords_history: [
{
text: 'Chart keyword counts by ranking position for',
field: 'target',
core: true,
},
{ text: ', since', field: 'dateFrom' },
],
ahrefs_batch_analysis: [
{ text: 'Compare SEO metrics across', field: 'targets', core: true },
{ text: ', in', field: 'country' },
],
ahrefs_site_audit_page_explorer: [
{ text: 'List crawled pages in audit project', field: 'projectId', core: true },
{ text: ', affected by issue', field: 'issueId' },
],
ahrefs_rank_tracker_overview: [
{
text: 'Read tracked keyword rankings in project',
field: 'projectId',
core: true,
},
{ text: ', on', field: 'date' },
],
ahrefs_rank_tracker_serp_overview: [
{ text: 'Read the full SERP for', field: 'keyword', core: true },
{ text: ', in project', field: 'projectId' },
],
ahrefs_rank_tracker_competitors_overview: [
{
text: 'Read per-keyword competitor rankings in project',
field: 'projectId',
core: true,
},
{ text: ', on', field: 'date' },
],
ahrefs_rank_tracker_competitors_stats: [
{
text: 'Read competitor traffic and share of voice in project',
field: 'projectId',
core: true,
},
{ text: ', on', field: 'date' },
],
},
},
},
subBlocks: [
{
id: 'operation',
+50
View File
@@ -5,6 +5,13 @@ import { AuthMode, IntegrationType } from '@/blocks/types'
import type { AirtableResponse } from '@/tools/airtable/types'
import { getTrigger } from '@/triggers'
/** Canonical account pair — an advanced-mode card only fills `manualCredential`. */
const ACCOUNT_FIELD = ['credential', 'manualCredential'] as const
/** Canonical base pair — the picker in basic mode, the raw base id in advanced. */
const BASE_FIELD = ['baseSelector', 'baseId'] as const
/** Canonical table pair — the picker in basic mode, the raw table id in advanced. */
const TABLE_FIELD = ['tableSelector', 'tableId'] as const
export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'airtable',
name: 'Airtable',
@@ -17,6 +24,49 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
integrationType: IntegrationType.Databases,
bgColor: '#FFFFFF',
icon: AirtableIcon,
canvasPresentation: {
defaultTitle: 'Airtable',
/*
* The trigger's own `tableId` shares a canonical group with the action
* block's `tableSelector`, which owns the basic slot — so the card resolves
* `tableId` only once it holds a value, and the clause cannot be `core`.
* Literal copy carries the untouched card instead.
*/
triggerSentences: {
default: ['Run on a record change', { text: 'in table', field: 'tableId' }],
},
sentences: {
byOperation: {
listBases: [{ text: 'List bases for', field: ACCOUNT_FIELD, core: true }],
listTables: [{ text: 'List tables in', field: BASE_FIELD, core: true }],
getSchema: [{ text: 'Read field and view schema of', field: BASE_FIELD, core: true }],
list: [
{ text: 'List records from', field: TABLE_FIELD, core: true },
{ text: ', where', field: 'filterFormula' },
{ text: ', up to', field: 'maxRecords', after: 'records' },
],
get: [
{ text: 'Fetch record', field: 'recordId', core: true },
{ text: 'from', field: TABLE_FIELD, core: true },
],
create: [{ text: 'Create records in', field: TABLE_FIELD, core: true }],
update: [
{ text: 'Update record', field: 'recordId', core: true },
{ text: 'in', field: TABLE_FIELD, core: true },
{ text: ', setting', field: 'fields' },
],
updateMultiple: [{ text: 'Update multiple records in', field: TABLE_FIELD, core: true }],
upsert: [
{ text: 'Upsert records into', field: TABLE_FIELD, core: true },
{ text: ', keyed on', field: 'fieldsToMergeOn' },
],
delete: [
{ text: 'Delete records', field: 'recordIds', core: true },
{ text: 'from', field: TABLE_FIELD, core: true },
],
},
},
},
subBlocks: [
{
id: 'operation',
+9
View File
@@ -16,6 +16,15 @@ export const AirweaveBlock: BlockConfig<AirweaveSearchResponse> = {
bgColor: '#6366F1',
iconColor: '#6366F1',
icon: AirweaveIcon,
canvasPresentation: {
defaultTitle: 'Airweave',
sentences: {
default: [
{ text: 'Search', field: 'collectionId', core: true },
{ text: 'for', field: 'query' },
],
},
},
subBlocks: [
{
id: 'collectionId',
+58
View File
@@ -14,6 +14,64 @@ export const AlgoliaBlock: BlockConfig = {
bgColor: '#003DFF',
iconColor: '#003DFF',
icon: AlgoliaIcon,
canvasPresentation: {
defaultTitle: 'Algolia',
sentences: {
byOperation: {
search: [
{ text: 'Search', field: 'indexName', core: true },
{ text: 'for', field: 'query' },
{ text: ', where', field: 'filters' },
],
add_record: [
{ text: 'Add record', field: 'objectID', core: true },
{ text: 'to', field: 'indexName', core: true },
],
get_record: [
{ text: 'Fetch record', field: 'objectID', core: true },
{ text: 'from', field: 'indexName', core: true },
],
get_records: [{ text: 'Fetch records in bulk from', field: 'indexName', core: true }],
partial_update_record: [
{ text: 'Update record', field: 'objectID', core: true },
{ text: 'in', field: 'indexName', core: true },
{ text: ', setting', field: 'attributes' },
],
delete_record: [
{ text: 'Delete record', field: 'objectID', core: true },
{ text: 'from', field: 'indexName', core: true },
],
browse_records: [
{ text: 'Browse every record in', field: 'indexName', core: true },
{ text: ', where', field: 'filters' },
],
batch_operations: [
{ text: 'Run batched record changes on', field: 'indexName', core: true },
],
list_indices: ['List all indices'],
get_settings: [{ text: 'Read settings of', field: 'indexName', core: true }],
update_settings: [{ text: 'Update settings of', field: 'indexName', core: true }],
delete_index: [{ text: 'Delete index', field: 'indexName', core: true }],
copy_move_index: [
{ text: 'Copy or move', field: 'indexName', core: true },
{ text: 'to', field: 'destination' },
],
clear_records: [{ text: 'Clear all records from', field: 'indexName', core: true }],
delete_by_filter: [
{ text: 'Delete records from', field: 'indexName', core: true },
{ text: ', matching', field: 'deleteFilters' },
],
get_task_status: [
{
text: 'Check indexing task',
field: 'taskID',
core: true,
},
{ text: 'on', field: 'indexName', core: true },
],
},
},
},
authMode: AuthMode.ApiKey,
subBlocks: [
+61
View File
@@ -1,6 +1,15 @@
import { AmplitudeIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types'
/*
* Amplitude identifies a person by user id or device id — either one alone is
* a valid identity, so the card sentences take whichever is filled. These are
* mutually exclusive alternates rather than a canonical basic/advanced pair,
* and the ingestion and profile operations declare their own field ids.
*/
const IDENTITY_FIELD = ['userId', 'deviceId'] as const
const PROFILE_IDENTITY_FIELD = ['profileUserId', 'profileDeviceId'] as const
export const AmplitudeBlock: BlockConfig = {
type: 'amplitude',
name: 'Amplitude',
@@ -13,6 +22,58 @@ export const AmplitudeBlock: BlockConfig = {
bgColor: '#13294B',
iconColor: '#1E61F0',
icon: AmplitudeIcon,
canvasPresentation: {
defaultTitle: 'Amplitude',
sentences: {
byOperation: {
send_event: [
{ text: 'Track event', field: 'eventType', core: true },
{ text: 'for', field: IDENTITY_FIELD },
{ text: 'on', field: 'platform' },
],
identify_user: [{ text: 'Set user properties on', field: IDENTITY_FIELD, core: true }],
group_identify: [
{ text: 'Set group properties on', field: 'groupValue', core: true },
{ text: 'of group type', field: 'groupType' },
],
user_search: [{ text: 'Look up user', field: 'searchUser', core: true }],
user_activity: [
{ text: 'List activity for Amplitude ID', field: 'amplitudeId', core: true },
{ text: ', up to', field: 'activityLimit', after: 'events' },
],
user_profile: [{ text: 'Read the profile of', field: PROFILE_IDENTITY_FIELD, core: true }],
event_segmentation: [
{ text: 'Segment event', field: 'segmentationEventType', core: true },
{ text: 'by', field: 'segmentationGroupBy' },
{ text: ', measuring', field: 'segmentationMetric' },
],
get_active_users: [
'Count active or new users',
{ text: 'from', field: 'activeUsersStart', core: true },
{ text: 'to', field: 'activeUsersEnd', core: true },
{ text: ', measuring', field: 'activeUsersMetric' },
],
realtime_active_users: ['Count active users in real time'],
list_events: ['List every event type in the project'],
get_revenue: [
'Report revenue',
{ text: 'from', field: 'revenueStart', core: true },
{ text: 'to', field: 'revenueEnd', core: true },
{ text: ', measuring', field: 'revenueMetric' },
],
funnels: [
'Measure funnel conversion',
{ text: 'from', field: 'funnelStart', core: true },
{ text: 'to', field: 'funnelEnd', core: true },
{ text: ', grouped by', field: 'funnelGroupBy' },
],
retention: [
{ text: 'Measure retention from', field: 'retentionStartEvent', core: true },
{ text: 'to', field: 'retentionReturnEvent', core: true },
],
},
},
},
authMode: AuthMode.ApiKey,
subBlocks: [
+10
View File
@@ -17,6 +17,16 @@ export const ApiBlock: BlockConfig<RequestResponse> = {
integrationType: IntegrationType.DevOps,
bgColor: '#2F55FF',
icon: ApiIcon,
canvasPresentation: {
defaultTitle: 'API',
sentences: {
default: [
{ text: 'Send', field: 'method', after: 'request to', core: true },
{ field: 'url', core: true },
{ text: ', with body', field: 'body' },
],
},
},
subBlocks: [
{
id: 'url',
+33
View File
@@ -19,6 +19,39 @@ export const ApifyBlock: BlockConfig<RunActorResult> = {
bgColor: '#FFFFFF',
icon: ApifyIcon,
canvasPresentation: {
defaultTitle: 'Apify',
sentences: {
byOperation: {
apify_run_actor_sync: [
{ text: 'Run actor', field: 'actorId', after: 'and wait for results', core: true },
],
apify_run_actor_async: [
{
text: 'Start actor',
field: 'actorId',
after: ', polling until it finishes',
core: true,
},
],
apify_run_task: [
{
text: 'Run saved task',
field: 'taskId',
after: 'and return its dataset items',
core: true,
},
],
apify_get_dataset_items: [
{ text: 'Fetch items from dataset', field: 'datasetId', core: true },
{ text: ', limited to', field: 'itemLimit' },
{ text: ', skipping', field: 'offset' },
],
apify_get_run: [{ text: 'Read the status of run', field: 'runId', core: true }],
},
},
},
subBlocks: [
{
id: 'operation',
+105
View File
@@ -5,6 +5,9 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import type { ApolloResponse } from '@/tools/apollo/types'
/** Identifies a person by email when available, otherwise by given name. */
const PERSON_IDENTITY_FIELD = ['email', 'first_name'] as const
export const ApolloBlock: BlockConfig<ApolloResponse> = {
type: 'apollo',
name: 'Apollo',
@@ -17,6 +20,107 @@ export const ApolloBlock: BlockConfig<ApolloResponse> = {
integrationType: IntegrationType.Sales,
bgColor: '#EBF212',
icon: ApolloIcon,
canvasPresentation: {
defaultTitle: 'Apollo',
sentences: {
byOperation: {
people_search: [
'Search for people',
{ text: ', with title', field: 'person_titles' },
{ text: ', in', field: 'person_locations' },
{ text: ', at', field: 'organization_names' },
],
people_enrich: [
{ text: 'Enrich person', field: PERSON_IDENTITY_FIELD, core: true },
{ text: 'at', field: ['organization_name', 'domain'] },
],
people_bulk_enrich: [{ text: 'Enrich the people in', field: 'people', core: true }],
organization_search: [
'Search for companies',
{ text: ', named', field: 'q_organization_name' },
{ text: ', in', field: 'organization_locations' },
{ text: ', with headcount', field: 'organization_num_employees_ranges' },
],
organization_enrich: [{ text: 'Enrich company', field: 'domain', core: true }],
organization_bulk_enrich: [
{ text: 'Enrich the companies in', field: 'domains', core: true },
],
contact_create: [
{ text: 'Create contact', field: PERSON_IDENTITY_FIELD, core: true },
{ text: ', titled', field: 'title' },
{ text: ', at', field: 'organization_name' },
],
contact_update: [
{ text: 'Update contact', field: 'contact_id', core: true },
{ text: ', with new title', field: 'title' },
{ text: ', at company', field: 'organization_name' },
],
contact_search: [
'Search saved contacts',
{ text: ', matching', field: 'q_keywords' },
{ text: ', in stage', field: 'contact_stage_ids' },
{ text: ', labeled', field: 'contact_label_ids' },
],
contact_bulk_create: [
{ text: 'Create the contacts in', field: 'contacts', core: true },
{ text: ', labeled', field: 'append_label_names' },
],
contact_bulk_update: [
{ text: 'Update the contacts in', field: 'contacts', core: true },
{ text: ', setting', field: 'contact_attributes' },
],
account_create: [
{ text: 'Create account', field: 'account_name', core: true },
{ text: ', at', field: 'domain' },
{ text: ', located in', field: 'raw_address' },
],
account_update: [
{ text: 'Update account', field: 'account_id', core: true },
{ text: ', with new name', field: 'account_name' },
{ text: ', at domain', field: 'domain' },
],
account_search: [
'Search saved accounts',
{ text: ', named', field: 'q_organization_name' },
{ text: ', in stage', field: 'account_stage_ids' },
{ text: ', labeled', field: 'account_label_ids' },
],
account_bulk_create: [
{ text: 'Create the accounts in', field: 'accounts', core: true },
{ text: ', labeled', field: 'append_label_names' },
],
account_bulk_update: [
{ text: 'Update the accounts in', field: 'accounts', core: true },
{ text: ', renaming each to', field: 'account_bulk_update_name' },
{ text: ', setting', field: 'account_attributes' },
],
opportunity_create: [
{ text: 'Create deal', field: 'opportunity_name', core: true },
{ text: ', worth', field: 'amount' },
{ text: ', closing', field: 'closed_date' },
],
opportunity_search: ['List all deals', { text: ', sorted by', field: 'sort_by_field' }],
opportunity_get: [{ text: 'Fetch deal', field: 'opportunity_id', core: true }],
opportunity_update: [
{ text: 'Update deal', field: 'opportunity_id', core: true },
{ text: ', renaming to', field: 'opportunity_name' },
{ text: ', setting amount to', field: 'amount' },
],
sequence_search: ['Search sequences', { text: ', named', field: 'q_name' }],
sequence_add: [
{ text: 'Add', field: 'contact_ids', core: true },
{ text: 'to sequence', field: 'sequence_id', core: true },
{ text: ', sending from', field: 'send_email_from_email_address' },
],
task_create: [
{ text: 'Create a task for contacts', field: 'contact_ids', core: true },
{ text: ', due', field: 'due_at' },
],
task_search: ['Search tasks', { text: ', sorted by', field: 'sort_by_field' }],
email_accounts: ['List linked email accounts'],
},
},
},
subBlocks: [
{
id: 'operation',
@@ -218,6 +322,7 @@ export const ApolloBlock: BlockConfig<ApolloResponse> = {
{
id: 'people',
title: 'People (JSON Array)',
canvasNoun: 'a JSON array',
type: 'code',
placeholder: '[{"first_name": "John", "last_name": "Doe", "email": "john@example.com"}]',
condition: { field: 'operation', value: 'people_bulk_enrich' },
+118
View File
@@ -20,6 +20,124 @@ export const AppConfigBlock: BlockConfig<
bgColor: 'linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)',
icon: AppConfigIcon,
authMode: AuthMode.ApiKey,
canvasPresentation: {
defaultTitle: 'AWS AppConfig',
sentences: {
byOperation: {
get_configuration: [
{ text: 'Read deployed configuration', field: 'configurationProfileId', core: true },
{ text: 'from environment', field: 'environmentId' },
],
list_applications: [
'List applications',
{ text: ', up to', field: 'maxResults', after: 'results' },
],
create_application: [{ text: 'Create application', field: 'name', core: true }],
get_application: [{ text: 'Fetch application', field: 'applicationId', core: true }],
update_application: [
{ text: 'Update application', field: 'applicationId', core: true },
{ text: ', renaming to', field: 'name' },
],
delete_application: [{ text: 'Delete application', field: 'applicationId', core: true }],
list_environments: [
{ text: 'List environments in', field: 'applicationId', core: true },
{ text: ', up to', field: 'maxResults', after: 'results' },
],
create_environment: [
{ text: 'Create environment', field: 'name', core: true },
{ text: 'in', field: 'applicationId' },
],
get_environment: [
{ text: 'Fetch environment', field: 'environmentId', core: true },
{ text: 'in', field: 'applicationId' },
],
update_environment: [
{ text: 'Update environment', field: 'environmentId', core: true },
{ text: ', renaming to', field: 'name' },
],
delete_environment: [
{ text: 'Delete environment', field: 'environmentId', core: true },
{ text: 'in', field: 'applicationId' },
],
list_configuration_profiles: [
{ text: 'List configuration profiles in', field: 'applicationId', core: true },
{ text: ', up to', field: 'maxResults', after: 'results' },
],
create_configuration_profile: [
{ text: 'Create configuration profile', field: 'name', core: true },
{ text: ', sourced from', field: 'locationUri' },
],
get_configuration_profile: [
{
text: 'Fetch configuration profile',
field: 'configurationProfileId',
core: true,
},
{ text: 'in', field: 'applicationId' },
],
update_configuration_profile: [
{
text: 'Update configuration profile',
field: 'configurationProfileId',
core: true,
},
{ text: ', renaming to', field: 'name' },
],
delete_configuration_profile: [
{
text: 'Delete configuration profile',
field: 'configurationProfileId',
core: true,
},
],
create_hosted_configuration_version: [
{
text: 'Add a hosted version to profile',
field: 'configurationProfileId',
core: true,
},
{ text: ', labeled', field: 'versionLabel' },
],
get_hosted_configuration_version: [
{ text: 'Read hosted version', field: 'versionNumber', core: true },
{ text: 'of profile', field: 'configurationProfileId' },
],
list_hosted_configuration_versions: [
{
text: 'List hosted versions of profile',
field: 'configurationProfileId',
core: true,
},
{ text: ', up to', field: 'maxResults', after: 'results' },
],
delete_hosted_configuration_version: [
{ text: 'Delete hosted version', field: 'versionNumber', core: true },
{ text: 'of profile', field: 'configurationProfileId' },
],
list_deployment_strategies: [
'List deployment strategies',
{ text: ', up to', field: 'maxResults', after: 'results' },
],
start_deployment: [
{ text: 'Deploy configuration version', field: 'configurationVersion', core: true },
{ text: 'to environment', field: 'environmentId' },
{ text: ', using strategy', field: 'deploymentStrategyId' },
],
get_deployment: [
{ text: 'Fetch deployment', field: 'deploymentNumber', core: true },
{ text: 'in environment', field: 'environmentId' },
],
list_deployments: [
{ text: 'List deployments in environment', field: 'environmentId', core: true },
{ text: ', up to', field: 'maxResults', after: 'results' },
],
stop_deployment: [
{ text: 'Stop deployment', field: 'deploymentNumber', core: true },
{ text: 'in environment', field: 'environmentId' },
],
},
},
},
subBlocks: [
{
id: 'operation',
+17
View File
@@ -14,6 +14,23 @@ export const ArxivBlock: BlockConfig<ArxivResponse> = {
integrationType: IntegrationType.Search,
bgColor: '#FFFFFF',
icon: ArxivIcon,
canvasPresentation: {
defaultTitle: 'ArXiv',
sentences: {
byOperation: {
arxiv_search: [
{ text: 'Search', field: 'searchField', after: 'for', core: true },
{ field: 'searchQuery', core: true },
{ text: ', up to', field: 'maxResults', after: 'results' },
],
arxiv_get_paper: [{ text: 'Fetch details for paper', field: 'paperId', core: true }],
arxiv_get_author_papers: [
{ text: 'List papers by', field: 'authorName', core: true },
{ text: ', up to', field: 'maxResults', after: 'results' },
],
},
},
},
subBlocks: [
{
id: 'operation',
+60
View File
@@ -4,6 +4,13 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import type { AsanaResponse } from '@/tools/asana/types'
const WORKSPACE_FIELD = ['workspaceSelector', 'workspace'] as const
const GET_TASKS_WORKSPACE_FIELD = ['getTasksWorkspaceSelector', 'getTasks_workspace'] as const
const CREATE_PROJECT_WORKSPACE_FIELD = [
'createProjectWorkspaceSelector',
'createProject_workspace',
] as const
export const AsanaBlock: BlockConfig<AsanaResponse> = {
type: 'asana',
name: 'Asana',
@@ -15,6 +22,59 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
integrationType: IntegrationType.Productivity,
bgColor: '#FFFFFF',
icon: AsanaIcon,
canvasPresentation: {
defaultTitle: 'Asana',
sentences: {
byOperation: {
get_task: [
{ text: 'Read task', field: 'taskGid', core: true },
{ text: 'in project', field: 'getTasks_project' },
{ text: ', from workspace', field: GET_TASKS_WORKSPACE_FIELD },
],
create_task: [
{ text: 'Create task', field: 'name', core: true },
{ text: 'in workspace', field: WORKSPACE_FIELD },
{ text: ', assigned to', field: 'assignee' },
],
update_task: [
{ text: 'Update task', field: 'taskGid', core: true },
{ text: ', renaming it', field: 'name' },
{ text: ', reassigning to', field: 'assignee' },
],
get_projects: [{ text: 'List projects in workspace', field: WORKSPACE_FIELD, core: true }],
search_tasks: [
{ text: 'Search tasks in workspace', field: WORKSPACE_FIELD, core: true },
{ text: ', matching', field: 'searchText' },
{ text: ', assigned to', field: 'assignee' },
],
add_comment: [
{ text: 'Add comment', field: 'commentText', core: true },
{ text: 'to task', field: 'taskGid', core: true },
],
create_subtask: [
{ text: 'Create subtask', field: 'name', core: true },
{ text: 'under task', field: 'subtaskParentGid', core: true },
{ text: ', assigned to', field: 'assignee' },
],
delete_task: [{ text: 'Delete task', field: 'taskGid', core: true }],
add_followers: [
{ text: 'Add', field: 'followers', core: true },
{ text: 'as followers of task', field: 'taskGid', core: true },
],
create_project: [
{ text: 'Create project', field: 'name', core: true },
{ text: 'in workspace', field: CREATE_PROJECT_WORKSPACE_FIELD },
],
get_project: [{ text: 'Read project', field: 'projectGid', core: true }],
list_workspaces: ['List all workspaces'],
create_section: [
{ text: 'Create section', field: 'name', core: true },
{ text: 'in project', field: 'projectGid', core: true },
],
list_sections: [{ text: 'List sections in project', field: 'projectGid', core: true }],
},
},
},
subBlocks: [
{
id: 'operation',
+86
View File
@@ -52,6 +52,92 @@ export const AshbyBlock: BlockConfig = {
iconColor: '#5D4ED6',
icon: AshbyIcon,
authMode: AuthMode.ApiKey,
canvasPresentation: {
defaultTitle: 'Ashby',
sentences: {
byOperation: {
list_candidates: ['List candidates', { text: ', created after', field: 'createdAfter' }],
get_candidate: [{ text: 'Read candidate', field: 'candidateId', core: true }],
create_candidate: [
{ text: 'Create candidate', field: 'name', core: true },
{ text: 'with email', field: 'email' },
{ text: ', reachable at', field: 'phoneNumber' },
],
update_candidate: [
{ text: 'Update candidate', field: 'candidateId', core: true },
{ text: ', renaming to', field: 'updateName' },
{ text: ', with email', field: 'email' },
],
search_candidates: [
'Search candidates',
{ text: ', by name', field: 'searchName' },
{ text: ', by email', field: 'searchEmail' },
],
list_jobs: [
'List jobs',
{ text: ', with status', field: 'jobStatus' },
{ text: ', opened after', field: 'openedAfter' },
{ text: ', opened before', field: 'openedBefore' },
],
get_job: [{ text: 'Read job', field: 'jobId', core: true }],
create_note: [
{ text: 'Add note', field: 'note', core: true },
{ text: 'to candidate', field: 'candidateId', core: true },
],
list_notes: [{ text: 'List notes on candidate', field: 'candidateId', core: true }],
list_applications: [
'List applications',
{ text: ', with status', field: 'filterStatus' },
{ text: ', for job', field: 'filterJobId' },
{ text: ', created after', field: 'createdAfter' },
],
get_application: [{ text: 'Read application', field: 'applicationId', core: true }],
create_application: [
{ text: 'Create an application for candidate', field: 'appCandidateId', core: true },
{ text: 'on job', field: 'jobId' },
],
list_offers: [
'List offers',
{ text: ', for application', field: 'offerApplicationId' },
{ text: ', created after', field: 'createdAfter' },
],
change_application_stage: [
{ text: 'Move application', field: 'applicationId', core: true },
{ text: 'to stage', field: 'interviewStageId' },
{ text: ', with archive reason', field: 'archiveReasonId' },
],
add_candidate_tag: [
{ text: 'Add tag', field: 'tagId', core: true },
{ text: 'to candidate', field: 'candidateId', core: true },
],
remove_candidate_tag: [
{ text: 'Remove tag', field: 'tagId', core: true },
{ text: 'from candidate', field: 'candidateId', core: true },
],
get_offer: [{ text: 'Read offer', field: 'offerId', core: true }],
list_sources: ['List candidate sources'],
list_candidate_tags: ['List candidate tags'],
list_archive_reasons: ['List archive reasons'],
list_custom_fields: ['List custom field definitions'],
list_departments: ['List departments'],
list_locations: ['List locations'],
list_job_postings: [
'List job postings',
{ text: ', in', field: 'postingLocation' },
{ text: ', for department', field: 'postingDepartment' },
{ text: ', on board', field: 'jobBoardId' },
],
get_job_posting: [{ text: 'Read job posting', field: 'jobPostingId', core: true }],
list_openings: ['List openings', { text: ', created after', field: 'createdAfter' }],
list_users: ['List users'],
list_interviews: [
'List interview schedules',
{ text: ', for application', field: 'applicationId' },
{ text: ', at stage', field: 'interviewStageId' },
],
},
},
},
triggers: {
enabled: true,
+52
View File
@@ -41,6 +41,58 @@ export const AthenaBlock: BlockConfig<
bgColor: 'linear-gradient(45deg, #4D27A8 0%, #A166FF 100%)',
iconColor: '#A166FF',
icon: AthenaIcon,
canvasPresentation: {
defaultTitle: 'Athena',
sentences: {
byOperation: {
start_query: [
{ text: 'Run', field: 'queryString', core: true },
{ text: 'on database', field: 'database' },
],
get_query_execution: [
{ text: 'Read the status of query', field: 'queryExecutionId', core: true },
],
get_query_results: [
{ text: 'Fetch the results of query', field: 'queryExecutionId', core: true },
{ text: ', up to', field: 'maxResults', after: 'rows' },
],
stop_query: [{ text: 'Stop query', field: 'queryExecutionId', core: true }],
list_query_executions: [
'List recent query executions',
{ text: ', in workgroup', field: 'workGroup' },
{ text: ', up to', field: 'maxResults' },
],
batch_get_query_execution: [
{
text: 'Read the status of every query in',
field: 'queryExecutionIds',
core: true,
},
],
create_named_query: [
{ text: 'Save named query', field: 'queryName', core: true },
{ text: 'on database', field: 'database' },
],
get_named_query: [{ text: 'Read named query', field: 'namedQueryId', core: true }],
list_named_queries: [
'List saved named queries',
{ text: ', in workgroup', field: 'workGroup' },
{ text: ', up to', field: 'maxResults' },
],
delete_named_query: [{ text: 'Delete named query', field: 'namedQueryId', core: true }],
list_databases: [
'List databases',
{ text: ', in catalog', field: 'catalogName' },
{ text: ', up to', field: 'maxResults' },
],
list_table_metadata: [
'List tables and their columns',
{ text: ', in database', field: 'databaseName' },
{ text: ', matching', field: 'expression' },
],
},
},
},
subBlocks: [
{
id: 'operation',
+173
View File
@@ -4,6 +4,20 @@ import { AuthMode, IntegrationType } from '@/blocks/types'
import type { AttioResponse } from '@/tools/attio/types'
import { getTrigger } from '@/triggers'
/*
* Canonical basic/advanced pairs, shared by the card sentences below. Naming
* both members is what keeps a sentence rendering for an advanced-mode user,
* who has only the manual field filled.
*/
const OBJECT_TYPE_FIELD = ['objectTypeSelector', 'objectType'] as const
const LIST_FIELD = ['listSelector', 'listIdOrSlug'] as const
/*
* A comment targets exactly one of an entry, a record, or a thread, chosen by
* the `commentTarget` dropdown. Not a canonical pair — mutually exclusive
* alternates, so the first one configured wins.
*/
const COMMENT_TARGET_FIELD = ['commentEntryId', 'commentRecordId', 'commentThreadId'] as const
export const AttioBlock: BlockConfig<AttioResponse> = {
type: 'attio',
name: 'Attio',
@@ -15,6 +29,165 @@ export const AttioBlock: BlockConfig<AttioResponse> = {
integrationType: IntegrationType.Sales,
bgColor: '#1D1E20',
icon: AttioIcon,
canvasPresentation: {
defaultTitle: 'Attio',
sentences: {
byOperation: {
list_records: [
{ text: 'List records in', field: OBJECT_TYPE_FIELD, core: true },
{ text: ', where', field: 'filter' },
{ text: ', sorted by', field: 'sorts' },
],
get_record: [
{ text: 'Read record', field: 'recordId', core: true },
{ text: 'from', field: OBJECT_TYPE_FIELD },
],
create_record: [
{ text: 'Create a record in', field: OBJECT_TYPE_FIELD, core: true },
{ text: ', with', field: 'values' },
],
update_record: [
{ text: 'Update record', field: 'recordId', core: true },
{ text: 'in', field: OBJECT_TYPE_FIELD },
{ text: ', setting', field: 'values' },
],
delete_record: [
{ text: 'Delete record', field: 'recordId', core: true },
{ text: 'from', field: OBJECT_TYPE_FIELD },
],
search_records: [
{ text: 'Search for', field: 'query', core: true },
{ text: 'across', field: 'objects' },
],
assert_record: [
{ text: 'Upsert a record in', field: OBJECT_TYPE_FIELD, core: true },
{ text: ', matched on', field: 'matchingAttribute' },
{ text: ', with', field: 'values' },
],
list_notes: [
'List notes',
{ text: ', on record', field: 'noteParentRecordId' },
{ text: ', in', field: 'noteParentObject' },
],
get_note: [{ text: 'Read note', field: 'noteId', core: true }],
create_note: [
{ text: 'Create note', field: 'noteTitle', core: true },
{ text: 'on record', field: 'noteParentRecordId', core: true },
{ text: 'in', field: 'noteParentObject' },
],
delete_note: [{ text: 'Delete note', field: 'noteId', core: true }],
list_tasks: [
'List tasks',
{ text: ', assigned to', field: 'taskFilterAssignee' },
{ text: ', linked to record', field: 'taskFilterRecordId' },
],
get_task: [{ text: 'Read task', field: 'taskId', core: true }],
create_task: [
{ text: 'Create task', field: 'taskContent', core: true },
{ text: ', due', field: 'taskDeadline' },
{ text: ', assigned to', field: 'taskAssignees' },
],
update_task: [
{ text: 'Update task', field: 'taskId', core: true },
{ text: ', due', field: 'taskDeadline' },
{ text: ', assigned to', field: 'taskAssignees' },
],
delete_task: [{ text: 'Delete task', field: 'taskId', core: true }],
list_objects: ['List every object in the workspace'],
get_object: [{ text: 'Read object', field: 'objectIdOrSlug', core: true }],
create_object: [
{ text: 'Create object', field: 'objectApiSlug', core: true },
{ text: ', named', field: 'objectSingularNoun' },
],
update_object: [
{ text: 'Update object', field: 'objectIdOrSlug', core: true },
{ text: ', renaming to', field: 'objectSingularNoun' },
{ text: ', with slug', field: 'objectApiSlug' },
],
list_lists: ['List every list in the workspace'],
get_list: [{ text: 'Read list', field: LIST_FIELD, core: true }],
create_list: [
{ text: 'Create list', field: 'listName', core: true },
{ text: 'on', field: 'listParentObject' },
],
update_list: [
{ text: 'Update list', field: LIST_FIELD, core: true },
{ text: ', renaming to', field: 'listName' },
{ text: ', with slug', field: 'listApiSlug' },
],
query_list_entries: [
{ text: 'Query entries in list', field: LIST_FIELD, core: true },
{ text: ', where', field: 'entryFilter' },
{ text: ', sorted by', field: 'entrySorts' },
],
get_list_entry: [
{ text: 'Read entry', field: 'entryId', core: true },
{ text: 'from list', field: LIST_FIELD },
],
create_list_entry: [
{ text: 'Add record', field: 'entryParentRecordId', core: true },
{ text: 'to list', field: LIST_FIELD },
{ text: ', with', field: 'entryValues' },
],
update_list_entry: [
{ text: 'Update entry', field: 'entryId', core: true },
{ text: 'in list', field: LIST_FIELD },
{ text: ', setting', field: 'entryValues' },
],
delete_list_entry: [
{ text: 'Remove entry', field: 'entryId', core: true },
{ text: 'from list', field: LIST_FIELD },
],
list_members: ['List every workspace member'],
get_member: [{ text: 'Read workspace member', field: 'memberId', core: true }],
create_comment: [
{ text: 'Post comment', field: 'commentContent', core: true },
{ text: 'on', field: COMMENT_TARGET_FIELD },
],
get_comment: [{ text: 'Read comment', field: 'commentId', core: true }],
delete_comment: [{ text: 'Delete comment', field: 'commentId', core: true }],
list_threads: [
'List comment threads',
{ text: ', on record', field: 'threadFilterRecordId' },
{ text: ', on entry', field: 'threadFilterEntryId' },
{ text: ', in list', field: 'threadFilterList' },
],
get_thread: [{ text: 'Read comment thread', field: 'threadId', core: true }],
list_webhooks: ['List every webhook in the workspace'],
get_webhook: [{ text: 'Read webhook', field: 'webhookId', core: true }],
create_webhook: [
{ text: 'Create a webhook to', field: 'webhookTargetUrl', core: true },
{ text: ', subscribed to', field: 'webhookSubscriptions' },
],
update_webhook: [
{ text: 'Update webhook', field: 'webhookId', core: true },
{ text: ', pointing to', field: 'webhookTargetUrl' },
{ text: ', subscribed to', field: 'webhookSubscriptions' },
],
delete_webhook: [{ text: 'Delete webhook', field: 'webhookId', core: true }],
list_attributes: [{ text: 'List attributes on', field: 'attributeIdentifier', core: true }],
get_attribute: [
{ text: 'Read attribute', field: 'attributeId', core: true },
{ text: 'on', field: 'attributeIdentifier' },
],
create_attribute: [
{
text: 'Create',
field: 'attributeType',
after: 'attribute',
core: true,
},
{ field: 'attributeTitle', core: true },
{ text: 'on', field: 'attributeIdentifier' },
],
update_attribute: [
{ text: 'Update attribute', field: 'attributeId', core: true },
{ text: 'on', field: 'attributeIdentifier' },
{ text: ', renaming to', field: 'attributeTitle' },
],
},
},
},
authMode: AuthMode.OAuth,
subBlocks: [
+68
View File
@@ -23,6 +23,73 @@ export const AzureDevOpsBlock: BlockConfig<AzureDevOpsResponse> = {
integrationType: IntegrationType.DevOps,
bgColor: '#0078D4',
icon: AzureIcon,
canvasPresentation: {
defaultTitle: 'Azure DevOps',
sentences: {
byOperation: {
azure_devops_list_pipelines: [{ text: 'List pipelines in', field: 'project', core: true }],
azure_devops_get_pipeline: [
{ text: 'Read pipeline', field: 'pipelineId', core: true },
{ text: 'in', field: 'project' },
],
azure_devops_list_pipeline_runs: [
{ text: 'List runs of pipeline', field: 'pipelineId', core: true },
{ text: 'in', field: 'project' },
],
azure_devops_get_pipeline_run: [
{ text: 'Read run', field: 'runId', core: true },
{ text: 'of pipeline', field: 'pipelineId' },
],
azure_devops_list_builds: [
{ text: 'List builds in', field: 'project', core: true },
{ text: ', with result', field: 'resultFilter' },
{ text: ', up to', field: 'top', after: 'results' },
],
azure_devops_list_build_logs: [
{ text: 'List logs for build', field: 'buildId', core: true },
{ text: 'in', field: 'project' },
],
azure_devops_get_build_log: [
{ text: 'Read log', field: 'logId', core: true },
{ text: 'from build', field: 'buildId' },
],
azure_devops_get_build_timeline: [
{ text: 'Read execution timeline of build', field: 'buildId', core: true },
],
azure_devops_get_work_items_between_builds: [
{ text: 'List work items from build', field: 'fromBuildId', core: true },
{ text: 'to build', field: 'toBuildId' },
],
azure_devops_query_work_items: [
{ text: 'Find work items matching', field: 'wiqlQuery', core: true },
],
azure_devops_get_work_item: [
{ text: 'Read work item', field: 'workItemId', core: true },
{ text: 'in', field: 'project' },
],
azure_devops_get_work_items_batch: [
{ text: 'Read details for work items', field: 'workItemIds', core: true },
],
azure_devops_create_work_item: [
{ text: 'Create', field: 'workItemType', core: true },
{ text: 'titled', field: 'title', core: true },
{ text: ', assigned to', field: 'assignedTo' },
],
azure_devops_update_work_item: [
{ text: 'Update work item', field: 'workItemId', core: true },
{ text: ', setting state to', field: 'state' },
{ text: ', assigning it to', field: 'assignedTo' },
],
azure_devops_add_comment: [
{ text: 'Comment', field: 'commentText', core: true },
{ text: 'on work item', field: 'workItemId', core: true },
],
azure_devops_get_comments: [
{ text: 'List comments on work item', field: 'workItemId', core: true },
],
},
},
},
authMode: AuthMode.ApiKey,
triggerAllowed: true,
@@ -152,6 +219,7 @@ export const AzureDevOpsBlock: BlockConfig<AzureDevOpsResponse> = {
{
id: 'fromBuildId',
title: 'From Build ID',
canvasNoun: 'a build ID',
type: 'short-input',
required: true,
condition: { field: 'operation', value: 'azure_devops_get_work_items_between_builds' },
+60
View File
@@ -4,6 +4,9 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
/** Canonical pair for the upload payload: file picker in basic mode, file reference in advanced. */
const UPLOAD_FILE_FIELD = ['uploadFile', 'fileRef'] as const
export const BoxBlock: BlockConfig = {
type: 'box',
name: 'Box',
@@ -16,6 +19,63 @@ export const BoxBlock: BlockConfig = {
bgColor: '#FFFFFF',
icon: BoxCompanyIcon,
authMode: AuthMode.OAuth,
canvasPresentation: {
defaultTitle: 'Box',
sentences: {
byOperation: {
upload_file: [
{ text: 'Upload', field: UPLOAD_FILE_FIELD, core: true },
{ text: 'to folder', field: 'parentFolderId' },
],
download_file: [{ text: 'Download file', field: 'fileId', core: true }],
get_file_info: [{ text: 'Read details of file', field: 'fileId', core: true }],
list_folder_items: [
{ text: 'List items in folder', field: 'folderId', core: true },
{ text: ', up to', field: 'limit', after: 'per page' },
],
create_folder: [
{ text: 'Create folder', field: 'folderName', core: true },
{ text: 'in parent folder', field: 'parentFolderId' },
],
delete_file: [{ text: 'Delete file', field: 'fileId', core: true }],
delete_folder: [{ text: 'Delete folder', field: 'folderId', core: true }],
copy_file: [
{ text: 'Copy file', field: 'fileId', core: true },
{ text: 'to folder', field: 'parentFolderId' },
{ text: ', as', field: 'copyName' },
],
search: [
{ text: 'Search files and folders for', field: 'query', core: true },
{ text: ', under folder', field: 'ancestorFolderId' },
],
update_file: [
{ text: 'Update file', field: 'fileId', core: true },
{ text: ', renaming to', field: 'newName' },
{ text: ', moving to folder', field: 'moveToFolderId' },
],
sign_create_request: [
{ text: 'Send files', field: 'sourceFileIds', core: true },
{ text: 'for signature to', field: 'signerEmail', core: true },
],
sign_get_request: [
{ text: 'Read status of sign request', field: 'signRequestId', core: true },
],
sign_list_requests: [
'List sign requests',
{ text: ', up to', field: 'limit', after: 'per page' },
],
sign_cancel_request: [{ text: 'Cancel sign request', field: 'signRequestId', core: true }],
sign_resend_request: [
{
text: 'Resend sign request',
field: 'signRequestId',
core: true,
after: 'to pending signers',
},
],
},
},
},
subBlocks: [
{
+9
View File
@@ -15,6 +15,15 @@ export const BrandfetchBlock: BlockConfig<BrandfetchGetBrandResponse | Brandfetc
bgColor: '#000000',
icon: BrandfetchIcon,
authMode: AuthMode.ApiKey,
canvasPresentation: {
defaultTitle: 'Brandfetch',
sentences: {
byOperation: {
get_brand: [{ text: 'Fetch brand assets for', field: 'identifier', core: true }],
search: [{ text: 'Search brands named', field: 'name', core: true }],
},
},
},
subBlocks: [
{
+137
View File
@@ -51,6 +51,13 @@ const PAGINATED_OPERATIONS = new Set([
'list_transfers',
])
/**
* Canonical basic/advanced pair for the receipt file, shared by the two receipt
* card sentences below. Listing both members is what keeps the sentence working
* for an advanced-mode user, who has only the file-reference field filled.
*/
const RECEIPT_FILE_FIELD = ['uploadReceiptFile', 'receiptFileReference'] as const
export const BrexBlock: BlockConfig<BrexResponse> = {
type: 'brex',
name: 'Brex',
@@ -63,6 +70,136 @@ export const BrexBlock: BlockConfig<BrexResponse> = {
integrationType: IntegrationType.Commerce,
bgColor: '#171717',
icon: BrexIcon,
canvasPresentation: {
defaultTitle: 'Brex',
sentences: {
byOperation: {
list_expenses: [
'List expenses',
{ text: ', for users', field: 'userIds' },
{ text: ', with status', field: 'statuses' },
{ text: ', up to', field: 'limit', after: 'results' },
],
get_expense: [{ text: 'Fetch expense', field: 'expenseId', core: true }],
update_expense: [
{ text: 'Set the memo of expense', field: 'expenseId', core: true },
{ text: 'to', field: 'memo' },
],
upload_receipt: [
{ text: 'Attach receipt', field: RECEIPT_FILE_FIELD, core: true },
{ text: 'to expense', field: 'expenseId', core: true },
],
match_receipt: [
{
text: 'Match receipt',
field: RECEIPT_FILE_FIELD,
after: 'to an existing expense',
core: true,
},
],
list_card_transactions: [
'List card transactions',
{ text: ', for users', field: 'userIds' },
{ text: ', posted after', field: 'postedAtStart' },
{ text: ', up to', field: 'limit', after: 'results' },
],
list_cash_transactions: [
{ text: 'List transactions in cash account', field: 'accountId', core: true },
{ text: ', posted after', field: 'postedAtStart' },
{ text: ', up to', field: 'limit', after: 'results' },
],
list_card_accounts: ['List all card accounts'],
list_cash_accounts: [
'List cash accounts',
{ text: ', up to', field: 'limit', after: 'results' },
],
get_cash_account: [
{
text: 'Fetch cash account',
field: 'accountId',
core: true,
},
],
list_card_statements: [
'List card statements',
{ text: ', up to', field: 'limit', after: 'results' },
],
list_cash_statements: [
{ text: 'List statements for cash account', field: 'accountId', core: true },
{ text: ', up to', field: 'limit', after: 'results' },
],
list_users: [
'List users',
{ text: ', with email', field: 'email' },
{ text: ', up to', field: 'limit', after: 'results' },
],
get_user: [{ text: 'Fetch user', field: 'userId', core: true }],
get_current_user: ['Fetch the authenticated user'],
list_departments: [
'List departments',
{ text: ', named', field: 'name' },
{ text: ', up to', field: 'limit', after: 'results' },
],
list_locations: [
'List locations',
{ text: ', named', field: 'name' },
{ text: ', up to', field: 'limit', after: 'results' },
],
list_titles: [
'List job titles',
{ text: ', named', field: 'name' },
{ text: ', up to', field: 'limit', after: 'results' },
],
list_cards: [
'List cards',
{ text: ', owned by', field: 'userId' },
{ text: ', up to', field: 'limit', after: 'results' },
],
get_company: ['Fetch company details'],
list_budgets: ['List budgets', { text: ', up to', field: 'limit', after: 'results' }],
get_budget: [{ text: 'Fetch budget', field: 'budgetId', core: true }],
create_budget: [
{ text: 'Create budget', field: 'resourceName', core: true },
{ text: ', capped at', field: 'amount' },
{ text: ', with a', field: 'periodRecurrenceType', after: 'period' },
],
archive_budget: [{ text: 'Archive budget', field: 'budgetId', core: true }],
list_spend_limits: [
'List spend limits',
{ text: ', for members', field: 'memberUserIds' },
{ text: ', up to', field: 'limit', after: 'results' },
],
get_spend_limit: [{ text: 'Fetch spend limit', field: 'spendLimitId', core: true }],
create_spend_limit: [
{ text: 'Create spend limit', field: 'resourceName', core: true },
{ text: ', capped at', field: 'baseLimitAmount' },
{ text: ', for members', field: 'spendLimitMemberUserIds' },
],
list_vendors: [
'List vendors',
{ text: ', named', field: 'name' },
{ text: ', up to', field: 'limit', after: 'results' },
],
get_vendor: [{ text: 'Fetch vendor', field: 'vendorId', core: true }],
create_vendor: [
{ text: 'Create vendor', field: 'companyName', core: true },
{ text: ', with email', field: 'vendorEmail' },
],
update_vendor: [
{ text: 'Update vendor', field: 'vendorId', core: true },
{ text: ', renaming it to', field: 'companyName' },
{ text: ', with email', field: 'vendorEmail' },
],
list_transfers: ['List transfers', { text: ', up to', field: 'limit', after: 'results' }],
get_transfer: [{ text: 'Fetch transfer', field: 'transferId', core: true }],
create_transfer: [
{ text: 'Transfer', field: 'amount', core: true },
{ text: 'from cash account', field: 'accountId' },
{ text: 'to vendor instrument', field: 'vendorPaymentInstrumentId', core: true },
],
},
},
},
subBlocks: [
{
id: 'operation',
+39
View File
@@ -15,6 +15,45 @@ export const BrightDataBlock: BlockConfig<BrightDataResponse> = {
integrationType: IntegrationType.Search,
bgColor: '#FFFFFF',
icon: BrightDataIcon,
canvasPresentation: {
defaultTitle: 'Bright Data',
sentences: {
byOperation: {
scrape_url: [
{ text: 'Scrape', field: 'url', core: true },
{ text: 'as', field: 'dataFormat' },
{ text: ', from', field: 'country' },
],
serp_search: [
{ text: 'Search', field: 'searchEngine', core: true },
{ text: 'for', field: 'query', core: true },
{ text: ', returning', field: 'numResults', after: 'results' },
],
discover: [
{ text: 'Discover and rank pages for', field: 'discoverQuery', core: true },
{ text: ', in', field: 'mode', after: 'mode' },
{ text: ', returning', field: 'numResults', after: 'results' },
],
sync_scrape: [
{ text: 'Scrape', field: 'syncUrls', core: true },
{ text: 'with scraper', field: 'syncDatasetId', core: true },
'and wait for results',
],
scrape_dataset: [
{ text: 'Queue a scraping job for', field: 'urls', core: true },
{ text: 'with scraper', field: 'datasetId', core: true },
],
snapshot_status: [
{ text: 'Check the progress of snapshot', field: 'snapshotId', core: true },
],
download_snapshot: [
{ text: 'Download snapshot', field: 'snapshotId', core: true },
{ text: 'as', field: 'downloadFormat' },
],
cancel_snapshot: [{ text: 'Cancel snapshot', field: 'snapshotId', core: true }],
},
},
},
subBlocks: [
{
id: 'operation',
+9
View File
@@ -14,6 +14,15 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
integrationType: IntegrationType.AI,
bgColor: '#181C1E',
icon: BrowserUseIcon,
canvasPresentation: {
defaultTitle: 'Browser Use',
sentences: {
default: [
{ text: 'Run the browser task', field: 'task', core: true },
{ text: ', starting at', field: 'startUrl' },
],
},
},
subBlocks: [
{
id: 'task',
+34
View File
@@ -18,6 +18,40 @@ export const BufferBlock: BlockConfig<BufferPostResponse> = {
bgColor: '#FFFFFF',
icon: BufferIcon,
authMode: AuthMode.ApiKey,
canvasPresentation: {
defaultTitle: 'Buffer',
sentences: {
byOperation: {
create_post: [
{ text: 'Create a post on channel', field: 'channelId', core: true },
{ text: ', saying', field: 'text' },
{ text: ', publishing at', field: 'dueAt' },
],
edit_post: [
{ text: 'Update post', field: 'postId', core: true },
{ text: ', to say', field: 'text' },
{ text: ', rescheduled for', field: 'dueAt' },
],
get_posts: [
'List posts',
{ text: ', on channels', field: 'channelIds' },
{ text: ', with status', field: 'status' },
{ text: ', up to', field: 'limit' },
],
get_post: [{ text: 'Fetch post', field: 'postId', core: true }],
delete_post: [{ text: 'Delete post', field: 'postId', core: true }],
get_channels: ['List connected channels'],
create_idea: [
{ text: 'Save idea', field: 'text', core: true },
{ text: ', titled', field: 'title' },
{ text: ', to group', field: 'groupId' },
],
get_ideas: ['List saved ideas', { text: ', up to', field: 'limit' }],
get_idea_groups: ['List idea groups'],
get_account: ['Read the authenticated account'],
},
},
},
subBlocks: [
{
+73
View File
@@ -4,6 +4,12 @@ import { AuthMode, IntegrationType } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
import { getTrigger } from '@/triggers'
const EVENT_TYPE_FIELD = ['eventTypeSelector', 'eventTypeId'] as const
const EVENT_TYPE_OR_SLUG_FIELD = ['eventTypeSelector', 'eventTypeId', 'eventTypeSlug'] as const
const EVENT_TYPE_PARAM_FIELD = ['eventTypeParamSelector', 'eventTypeIdParam'] as const
const EVENT_TYPE_SCHEDULE_FIELD = ['eventTypeScheduleSelector', 'eventTypeScheduleId'] as const
const SCHEDULE_FIELD = ['scheduleSelector', 'scheduleId'] as const
export const CalComBlock: BlockConfig<ToolResponse> = {
type: 'calcom',
name: 'Cal.com',
@@ -17,6 +23,73 @@ export const CalComBlock: BlockConfig<ToolResponse> = {
integrationType: IntegrationType.Productivity,
bgColor: '#292929',
icon: CalComIcon,
canvasPresentation: {
defaultTitle: 'Cal.com',
sentences: {
byOperation: {
calcom_list_bookings: ['List bookings', { text: ', with status', field: 'bookingStatus' }],
calcom_create_booking: [
{ text: 'Book', field: EVENT_TYPE_FIELD, core: true },
{ text: 'for', field: 'attendeeName' },
{ text: ', at', field: 'start' },
],
calcom_get_booking: [{ text: 'Fetch booking', field: 'bookingUid', core: true }],
calcom_cancel_booking: [
{ text: 'Cancel booking', field: 'bookingUid', core: true },
{ text: ', citing', field: 'cancellationReason' },
],
calcom_reschedule_booking: [
{ text: 'Reschedule booking', field: 'bookingUid', core: true },
{ text: 'to', field: 'start' },
{ text: ', citing', field: 'reschedulingReason' },
],
calcom_confirm_booking: [
{ text: 'Confirm pending booking', field: 'bookingUid', core: true },
],
calcom_decline_booking: [
{ text: 'Decline pending booking', field: 'bookingUid', core: true },
],
calcom_create_event_type: [
{ text: 'Create event type', field: 'title', core: true },
{ text: ', lasting', field: 'eventLength', after: 'minutes' },
{ text: ', on schedule', field: EVENT_TYPE_SCHEDULE_FIELD },
],
calcom_get_event_type: [
{ text: 'Fetch event type', field: EVENT_TYPE_PARAM_FIELD, core: true },
],
calcom_list_event_types: [
'List event types',
{ text: ', sorted', field: 'sortCreatedAt', after: 'by creation date' },
],
calcom_update_event_type: [
{ text: 'Update event type', field: EVENT_TYPE_PARAM_FIELD, core: true },
{ text: ', renaming it to', field: 'title' },
{ text: ', lasting', field: 'eventLength', after: 'minutes' },
],
calcom_delete_event_type: [
{ text: 'Delete event type', field: EVENT_TYPE_PARAM_FIELD, core: true },
],
calcom_create_schedule: [
{ text: 'Create availability schedule', field: 'name', core: true },
{ text: ', in', field: 'timeZone' },
],
calcom_get_schedule: [{ text: 'Fetch schedule', field: SCHEDULE_FIELD, core: true }],
calcom_list_schedules: ['List availability schedules'],
calcom_update_schedule: [
{ text: 'Update schedule', field: SCHEDULE_FIELD, core: true },
{ text: ', renaming it to', field: 'name' },
{ text: ', in', field: 'timeZone' },
],
calcom_delete_schedule: [{ text: 'Delete schedule', field: SCHEDULE_FIELD, core: true }],
calcom_get_default_schedule: ['Fetch the default availability schedule'],
calcom_get_slots: [
{ text: 'Find open slots for', field: EVENT_TYPE_OR_SLUG_FIELD, core: true },
{ text: ', from', field: 'start' },
{ text: ', until', field: 'end' },
],
},
},
},
subBlocks: [
{
id: 'operation',
+39
View File
@@ -4,6 +4,12 @@ import { AuthMode, IntegrationType } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
import { getTrigger } from '@/triggers'
/**
* The list endpoints scope by a user URI or an organization URI, never both, so
* the clause takes whichever the user filled.
*/
const SCOPE_FIELD = ['user', 'organization'] as const
export const CalendlyBlock: BlockConfig<ToolResponse> = {
type: 'calendly',
name: 'Calendly',
@@ -17,6 +23,39 @@ export const CalendlyBlock: BlockConfig<ToolResponse> = {
integrationType: IntegrationType.Productivity,
bgColor: '#FFFFFF',
icon: CalendlyIcon,
canvasPresentation: {
defaultTitle: 'Calendly',
triggerSentences: {
byTrigger: {
calendly_webhook: ['Run on any scheduling event'],
},
},
sentences: {
byOperation: {
calendly_get_current_user: ['Read the connected account profile'],
calendly_list_event_types: ['List event types', { text: ', for', field: SCOPE_FIELD }],
calendly_get_event_type: [{ text: 'Read event type', field: 'eventTypeUuid', core: true }],
calendly_list_scheduled_events: [
'List scheduled events',
{ text: ', for', field: SCOPE_FIELD },
{ text: ', with invitee', field: 'invitee_email' },
{ text: ', starting after', field: 'min_start_time' },
],
calendly_get_scheduled_event: [
{ text: 'Read scheduled event', field: 'eventUuid', core: true },
],
calendly_list_event_invitees: [
{ text: 'List invitees of event', field: 'eventUuid', core: true },
{ text: ', matching', field: 'email' },
{ text: ', with status', field: 'status' },
],
calendly_cancel_event: [
{ text: 'Cancel event', field: 'eventUuid', core: true },
{ text: ', with reason', field: 'reason' },
],
},
},
},
subBlocks: [
{
id: 'operation',
+7
View File
@@ -13,6 +13,12 @@ export const ClayBlock: BlockConfig<ClayPopulateResponse> = {
integrationType: IntegrationType.Sales,
bgColor: '#FFFFFF',
icon: ClayIcon,
canvasPresentation: {
defaultTitle: 'Clay',
sentences: {
default: [{ text: 'Populate a table with', field: 'data', core: true }],
},
},
subBlocks: [
{
id: 'webhookURL',
@@ -24,6 +30,7 @@ export const ClayBlock: BlockConfig<ClayPopulateResponse> = {
{
id: 'data',
title: 'Data (JSON or Plain Text)',
canvasNoun: 'a record',
type: 'long-input',
placeholder: 'Enter your JSON data to populate your Clay table',
required: true,

Some files were not shown because too many files have changed in this diff Show More