mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 01:23:02 +08:00
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:
co-authored by
Claude Opus 5
andresdjasso
Waleed
parent
f5c06d4895
commit
245ad1bd46
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+56
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
+138
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
+66
@@ -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
|
||||
}
|
||||
+81
-56
@@ -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;
|
||||
}
|
||||
|
||||
+16
-45
@@ -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)) {
|
||||
|
||||
+259
-25
@@ -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))
|
||||
|
||||
+1
@@ -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'
|
||||
|
||||
+589
-224
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+18
-22
@@ -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()
|
||||
|
||||
+1
-7
@@ -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'
|
||||
|
||||
+502
@@ -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>
|
||||
)
|
||||
}
|
||||
+247
-33
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
+192
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+83
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+35
@@ -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]
|
||||
)
|
||||
}
|
||||
+6
-15
@@ -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
@@ -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'
|
||||
|
||||
+55
@@ -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()
|
||||
})
|
||||
})
|
||||
+132
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+110
-32
@@ -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>
|
||||
|
||||
+14
-16
@@ -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')
|
||||
|
||||
+1
-7
@@ -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',
|
||||
|
||||
+34
-19
@@ -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>
|
||||
|
||||
|
||||
+28
-14
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
+1
-7
@@ -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',
|
||||
|
||||
+1
-7
@@ -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
|
||||
)
|
||||
|
||||
+435
-99
@@ -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)
|
||||
|
||||
+1
-8
@@ -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={{
|
||||
|
||||
+38
-3
@@ -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,
|
||||
|
||||
+71
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-3
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-1
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+27
-8
@@ -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)
|
||||
|
||||
+3
-1
@@ -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
+1
-7
@@ -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',
|
||||
|
||||
+253
-109
@@ -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
|
||||
|
||||
+14
-120
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+44
-5
@@ -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,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
|
||||
+20
-13
@@ -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
|
||||
)
|
||||
|
||||
|
||||
+4
-1
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
+6
-1
@@ -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(() => {
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
|
||||
+1
-7
@@ -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',
|
||||
|
||||
+17
-1
@@ -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()
|
||||
|
||||
|
||||
+36
-34
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user