feat(workflows): add workflow editor (#6248)

**Category:** new-feature
**User Impact:** Users can create, edit, duplicate, and deep-link to
workflows in a responsive visual editor without losing unsupported YAML
or unsaved work.
**Problem:** Workflow editing was split across disconnected surfaces and
lacked reliable URL state, lifecycle protection, and parity between Form
and YAML modes. **Solution:** This adds a route-addressable editor
foundation with stable pane identity, guarded dirty exits, lossless
Form/YAML transitions, responsive workflow and channel controls, and
matching reaction-filter execution support.

<details>
<summary>File changes</summary>

**crates/buzz-workflow/src/lib.rs**
Apply reaction trigger filters during workflow execution and cover
target-message gating.

**crates/buzz-workflow/src/schema.rs**
Extend the reaction trigger schema with the editor-owned filter field.

**desktop/src/app/navigation/useAppNavigation.ts**
Add navigation helpers for explicit workflow create, edit, and duplicate
editor modes.

**desktop/src/app/routes/WorkflowsRouteScreen.tsx**
Coordinate route state with the shared workflow library and editor
dialog.

**desktop/src/app/routes/lazyWorkflowsRouteScreen.ts**
Share one lazy route component across workflow route entry points to
avoid loading flashes.

**desktop/src/app/routes/workflows.$workflowId.tsx**
Parse workflow editor modes and pane deep links for workflow-specific
URLs.

**desktop/src/app/routes/workflows.tsx**
Parse library-level create state and render the shared workflow route
screen.

**desktop/src/app/AppWorkflowEditorOverlayProvider.tsx**
Host the shared workflow editor at the app-shell level so
channel-originated workflow dialogs stay above the active channel
instead of replacing it.

**desktop/src/shared/context/WorkflowEditorOverlayContext.tsx**
Expose route-independent open-existing and create-new workflow actions
to channel settings.

**desktop/src/features/workflows/ui/WorkflowEditorHost.tsx**
Share editor loading, unavailable, and dialog lifecycle wiring between
canonical workflow routes and the channel overlay.

**desktop/src/features/channels/ui/ChannelManagementSheet.tsx**
Add an experiment-gated, Canvas-style Workflows ingress below Canvas,
including channel-scoped loading, error, empty, and list states plus
open/create actions; disabled users issue no workflow query.

**desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx**
Render the channel workflow list and New workflow action without pushing
the existing settings sheet past its file-size ceiling.

**desktop/src/features/workflows/ui/ChannelCombobox.tsx**
Adopt the final channel presentation, portalled scrolling, and one-shot
create-flow opening behavior.

**desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx**
Remove the superseded create-only dialog in favor of the unified
workflow editor.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**
Open workflow cards in the detail and run-history modal while preserving
explicit edit and duplicate actions.

**desktop/src/features/workflows/ui/WorkflowDetailDialog.tsx**
Present workflow Trigger/Steps in the shared modal chrome, with
top-chrome ingress to a responsive right-side run-history inspector and
an explicit edit action.

**desktop/src/features/workflows/ui/WorkflowDialog.tsx**
Unify create, edit, and duplicate lifecycle handling with URL panes,
generated-name synchronization, dirty-exit guards, stale-write
preservation, and protected webhook-secret handoff.

**desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx**
Build the responsive Form/YAML editor shell, stable step selection,
insertion and removal behavior, and lossless canonical-YAML
synchronization.

**desktop/src/features/workflows/ui/WorkflowStepCard.tsx**
Align workflow step controls and presentation with the final editor
interaction model.

**desktop/src/features/workflows/ui/WorkflowUnavailableDialog.tsx**
Show a non-disclosing loading or unavailable state for missing and
inaccessible workflow links, with retry and close actions.

**desktop/src/features/workflows/ui/WorkflowWebhookSecretDialog.tsx**
Obscure one-time webhook secrets by default and require explicit
confirmation before any close or navigation discards them.

**desktop/src/features/workflows/ui/WorkflowsScreen.tsx**
Connect library state and workflow actions to the route-addressable
editor.

**desktop/src/features/workflows/ui/WorkflowsView.tsx**
Restore the responsive workflow library, create tile, cards, loading
states, and shared action menu.

**desktop/src/features/workflows/ui/workflowEditorPane.test.mjs**
Cover pane parsing, serialization, and stable step-ID reconciliation.

**desktop/src/features/workflows/ui/workflowEditorPane.ts**
Define explicit trigger and stable step pane URL state.

**desktop/src/features/workflows/ui/workflowYamlDocument.ts**
Read and update header fields independently of full form validation so
incomplete steps cannot clear or disable the workflow title.

**desktop/src/features/workflows/ui/workflowYamlDocument.test.mjs**
Cover document-level workflow header reads and writes for incomplete
definitions.

**desktop/tests/e2e/workflow-title-stability.spec.ts**
Verify generated, renamed, saved, and duplicated titles remain stable
while moving between trigger and step panes.

**desktop/src/features/workflows/ui/workflowFormTypes.test.mjs**
Cover lossless Form/YAML round trips and actionable fallback for
unsupported fields.

**desktop/src/features/workflows/ui/workflowFormTypes.ts**
Own canonical workflow YAML conversion while preserving supported
trigger and step fields.

**desktop/src/shared/ui/PortalledScrollArea.tsx**
Provide bounded scrolling for popovers rendered outside their dialog
container.

**desktop/src/shared/ui/popover.tsx**
Allow workflow popovers to use the shared portalled scroll container.

**desktop/tests/e2e/channels.spec.ts**
Cover disabled and enabled Workflows experiment states, including
suppressed queries while disabled, placement beneath Canvas, channel
workflow listing and opening, channel-preselected workflow creation, and
direct return to the channel Workflows panel after close, discard, or
cancel.

**desktop/tests/e2e/workflows.spec.ts**
Exercise library actions, deep links, create/edit/duplicate lifecycle,
dirty exits, responsive editor behavior, YAML safety, stale updates, and
one-shot channel selection.

</details>

## Reproduction steps

1. Open **Workflows** and confirm the responsive card library, create
tile, card action menu, and card-to-detail/run-history modal navigation.
2. Open `?view=create`; confirm the channel chooser opens once, the
trigger inspector stays hidden until a channel is selected, and closing
the chooser does not make it reopen after unrelated edits.
3. Create a workflow, switch between Form and YAML, add and remove
steps, refresh a pane deep link, and confirm the selected trigger or
stable step remains addressable.
4. Edit or duplicate a workflow, make an unsaved change, and confirm
close, Escape, browser navigation, and route target changes require
discard confirmation while pane-only navigation does not.
5. Enter unsupported YAML and confirm Form mode gives an actionable
fallback without rewriting the definition; verify reaction triggers
preserve and execute their filter.

6. Open a channel’s settings, select **Workflows** below **Canvas**, and
open or create a workflow; confirm the shared modal stays over the
channel, the channel URL does not change, New workflow preselects that
channel, and closing or discarding returns directly to the channel’s
**Workflows** panel.
7. In create, edit, and duplicate modes, move between the trigger and
incomplete step panes and confirm the generated or edited title remains
visible and editable.

## Screenshots

Fresh captures from product head
`c5c3abc91a71fe511d43e6cc9168b1626d0c217c`; the later review-guidance
fix does not alter these pictured states.

### Workflow library and actions

![Workflow library with action
menu](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-library-actions-c5c3abc.png)

### Workflow editor — wide

![Wide workflow editor with step
details](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-editor-wide-c5c3abc.png)

### Workflow editor — narrow inspector overlay

![Narrow workflow editor with inspector
overlay](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-editor-narrow-c5c3abc.png)

### Workflow editor — active channel overlay

![Workflow editor portalled over the active
channel](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-editor-channel-overlay-c5c3abc.png)

## Review feedback addressed

- Corrected Diff Posted condition guidance to use the executor-supported
`str_contains(trigger_text, "deploy")` syntax and added a visible
Playwright regression assertion.
- Scoped direct workflow-detail navigation coverage to the stable `Edit
workflow` dialog name, asserted the workflow title separately, and
retained the trigger-node assertion.
- Routed dirty channel-overlay Duplicate/Edit transitions through the
existing discard confirmation, preserving the original YAML draft when
the user keeps editing.
- Made workflow deletion await relay success, remain non-dismissible
while pending, and retain the confirmation/editor/draft with an
actionable inline error on rejection.
- Gated the channel-settings Workflows ingress and channel workflow
query behind the `workflows` experiment, with defensive rendering if the
flag changes while that view is active.
- Matched the Reaction Added trigger to the prototype reaction picker,
preserving native/custom/legacy values in canonical YAML and providing
an explicit clear action.
- Kept a channel’s Workflows panel mounted beneath channel-origin
editors so clean close, dirty discard, and create cancel return directly
to that panel without changing the channel URL.

## Verification

Verified at exact pushed head
`76ebba7b7ace1e13445744200da70ca9da231b7e`:

- Push hooks passed: destination-org policy, branch-skew, differential
file-size, Desktop checks, TypeScript typecheck, and **5,115/5,115**
Desktop unit tests
- Focused channel lifecycle E2E passed: dirty edit discard returns
directly to the channel Workflows panel; create cancel does the same
while preserving the channel URL and preselected channel
- E2E production build, standalone TypeScript typecheck, and Biome
checks on both touched files passed
- Reaction-picker regression spec remains recorded at
`dc28ffa98ec34b4e0656757fb2dd93e60a84674d`: **5/5 passed** (picker
interaction, canonical YAML persistence/clear, legacy-value
preservation, save/reopen round trip, narrow viewport containment)
- Blox existing workflow E2E regression set at
`dc28ffa98ec34b4e0656757fb2dd93e60a84674d`: **36/36 passed**
- Earlier blocker and review regressions remain recorded at
`3760c3d657a525f5af98e8d0f98bdd03999d8e61`: **5,114/5,114** Desktop unit
tests and the dirty-overlay/deletion focused checks
- Working tree clean; local branch, remote branch, and PR head all match
the exact SHA above

### Related issue

None found. Closest prior work: #231.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Taylor Ho
2026-08-20 11:15:56 -07:00
committed by GitHub
parent 7ebe3ea699
commit 1934e83bf5
54 changed files with 7561 additions and 1146 deletions

View File

@@ -887,6 +887,7 @@ async fn should_fire_workflow(
) -> bool {
if let TriggerDef::ReactionAdded {
emoji: Some(ref expected),
..
} = def.trigger
{
if &trigger_ctx.emoji != expected {
@@ -900,33 +901,13 @@ async fn should_fire_workflow(
}
}
if let TriggerDef::MessagePosted {
filter: Some(ref expr),
} = def.trigger
{
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
Ok(true) => {}
Ok(false) => {
tracing::debug!(
workflow_id = %workflow_id,
"Trigger filter evaluated false — skipping workflow"
);
return false;
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Trigger filter error: {e} — skipping workflow"
);
return false;
}
}
}
if let TriggerDef::DiffPosted {
filter: Some(ref expr),
} = def.trigger
{
let filter = match &def.trigger {
TriggerDef::MessagePosted { filter }
| TriggerDef::ReactionAdded { filter, .. }
| TriggerDef::DiffPosted { filter } => filter.as_ref(),
TriggerDef::Schedule { .. } | TriggerDef::Webhook => None,
};
if let Some(expr) = filter {
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
Ok(true) => {}
Ok(false) => {
@@ -1364,7 +1345,10 @@ steps:
#[test]
fn trigger_matches_reaction() {
let trigger = TriggerDef::ReactionAdded { emoji: None };
let trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
assert!(trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
@@ -1375,6 +1359,36 @@ steps:
));
}
#[tokio::test]
async fn reaction_filter_matches_target_message() {
let yaml = r#"
name: "React to one message"
trigger:
on: reaction_added
filter: 'trigger_message_id == "target-message"'
steps:
- id: wait
action: delay
duration: 1s
"#;
let (def, _) = WorkflowEngine::parse_yaml(yaml).expect("parse failed");
let mut trigger_ctx = executor::TriggerContext {
message_id: "target-message".to_owned(),
..Default::default()
};
assert!(
should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
"reaction to the selected message should fire"
);
trigger_ctx.message_id = "different-message".to_owned();
assert!(
!should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
"reaction to a different message should be filtered out"
);
}
#[test]
fn schedule_trigger_never_matches_events() {
let trigger = TriggerDef::Schedule {
@@ -1421,7 +1435,10 @@ steps:
#[test]
fn reaction_added_matches_kind_7_only() {
let trigger = TriggerDef::ReactionAdded { emoji: None };
let trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
// Must match KIND_REACTION = 7.
assert!(trigger_matches_event(&trigger, 7));
// Must NOT match stream message (kind 9).
@@ -1436,6 +1453,7 @@ steps:
// trigger_matches_event only checks the kind number.
let trigger = TriggerDef::ReactionAdded {
emoji: Some("thumbsup".to_owned()),
filter: None,
};
assert!(trigger_matches_event(&trigger, 7));
assert!(!trigger_matches_event(&trigger, 9));
@@ -1458,7 +1476,10 @@ steps:
// before calling trigger_matches_event, but verify the function itself
// also returns false for these kinds.
let msg_trigger = TriggerDef::MessagePosted { filter: None };
let react_trigger = TriggerDef::ReactionAdded { emoji: None };
let react_trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
for kind in buzz_core::kind::KIND_WORKFLOW_TRIGGERED
..=buzz_core::kind::KIND_WORKFLOW_APPROVAL_DENIED
@@ -1478,7 +1499,10 @@ steps:
fn trigger_matches_event_kind_zero_matches_nothing() {
// Kind 0 is a profile event — no trigger should match it.
let msg_trigger = TriggerDef::MessagePosted { filter: None };
let react_trigger = TriggerDef::ReactionAdded { emoji: None };
let react_trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
let sched_trigger = TriggerDef::Schedule {
cron: None,
interval: Some("1h".to_owned()),
@@ -1715,7 +1739,11 @@ steps:
async fn setup_db() -> buzz_db::Db {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned());
// Local-only test default; this is not a production credential.
.unwrap_or_else(|_| {
let local_test_database = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
local_test_database.to_owned()
});
buzz_db::Db::new(&buzz_db::DbConfig {
database_url,
..Default::default()

View File

@@ -47,6 +47,9 @@ pub enum TriggerDef {
/// Optional: only fire for this specific emoji.
#[serde(default)]
emoji: Option<String>,
/// Optional evalexpr filter over the reaction context.
#[serde(default)]
filter: Option<String>,
},
/// Fires when a diff message (kind:40008) is posted in the workflow's channel.
DiffPosted {
@@ -300,11 +303,12 @@ mod tests {
#[test]
fn parse_reaction_added_trigger() {
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\n filter: 'trigger_message_id == \"abc123\"'\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";
let (def, _) = parse_yaml(yaml).expect("parse failed");
match &def.trigger {
TriggerDef::ReactionAdded { emoji } => {
TriggerDef::ReactionAdded { emoji, filter } => {
assert_eq!(emoji.as_deref(), Some("clipboard"));
assert_eq!(filter.as_deref(), Some("trigger_message_id == \"abc123\""));
}
other => panic!("unexpected trigger: {other:?}"),
}
@@ -488,8 +492,9 @@ mod tests {
let yaml = "name: Any Reaction\ntrigger:\n on: reaction_added\nsteps:\n - id: s1\n action: add_reaction\n emoji: eyes\n";
let (def, _) = parse_yaml(yaml).expect("parse failed");
match &def.trigger {
TriggerDef::ReactionAdded { emoji } => {
TriggerDef::ReactionAdded { emoji, filter } => {
assert!(emoji.is_none(), "emoji should default to None");
assert!(filter.is_none(), "filter should default to None");
}
other => panic!("unexpected trigger: {other:?}"),
}

View File

@@ -73,6 +73,9 @@ export default defineConfig({
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-affordance.spec.ts",
"**/workflows.spec.ts",
"**/workflow-reaction-picker.spec.ts",
"**/workflow-local-controls.spec.ts",
"**/workflow-title-stability.spec.ts",
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity.spec.ts",

View File

@@ -101,6 +101,7 @@ import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu";
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
import { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlayProvider";
import { LazySettingsScreen } from "@/app/LazySettingsScreen";
const EMPTY_CHANNELS: Channel[] = [];
export function AppShell() {
@@ -764,216 +765,222 @@ export function AppShell() {
data-testid="app-sidebar-layer"
>
<AppProfilePanelProvider>
{!settingsOpen && !isHuddleRoom ? (
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
hasCommunityRail={hasCommunityRail}
onGoBack={goBack}
onGoForward={goForward}
/>
) : null}
{settingsOpen ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={handleSettingsSectionChange}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetSlotAlertsEnabled={
notificationSettings.setSlotAlertsEnabled
}
onSetNotifyWhileViewing={
notificationSettings.setNotifyWhileViewing
}
onSetAllSlotAlertsEnabled={
notificationSettings.setAllSlotAlertsEnabled
}
onSetSoundForSlot={notificationSettings.setSoundForSlot}
section={settingsSection}
/>
</React.Suspense>
</div>
) : (
<div className="relative flex min-h-0 flex-1 overflow-visible">
{!isHuddleRoom ? (
<AppSidebar
activeCommunity={communitiesHook.activeCommunity}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={channelsErrorMessage}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount + dueReminderBadge}
addCommunityPrefill={addCommunityDialog.prefill}
isAddCommunityOpen={addCommunityDialog.open}
relayConnectionCard={relayConnectionCard}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isCreateChannelOpen={isCreateChannelOpen}
isHuddleCompanionOpen={isHuddleCompanionOpen}
isPresencePending={presenceSession.isPending}
onAddCommunity={(community) => {
const id = communitiesHook.addCommunity({
...community,
pubkey:
community.pubkey ?? identityQuery.data?.pubkey,
});
handleSwitchCommunity(id);
}}
onAddCommunityOpenChange={
addCommunityDialog.onOpenChange
}
onNewMessage={goNewMessage}
onBackgroundClick={requestFocusedThreadClose}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={handleRemoveCommunity}
onSwitchCommunity={handleSwitchCommunity}
onCreateAgent={() => requestOpenCreateAgent()}
selfPresenceStatus={presenceSession.currentStatus}
communities={communitiesHook.communities}
onCreateChannel={handleCreateChannel}
onCreateForum={handleCreateForum}
onHideDm={handleHideDm}
onHuddleEnded={handleHuddleEnded}
onMarkAllChannelsRead={markAllChannelsRead}
onMarkChannelRead={markChannelRead}
onMarkChannelUnread={markChannelUnread}
onBrowseChannels={handleOpenBrowseChannels}
onOpenDm={async ({ pubkeys }) => {
const directMessage =
await openDmMutation.mutateAsync({
pubkeys,
<AppWorkflowEditorOverlayProvider>
{!settingsOpen && !isHuddleRoom ? (
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
hasCommunityRail={hasCommunityRail}
onGoBack={goBack}
onGoForward={goForward}
/>
) : null}
{settingsOpen ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={
notificationSettings.permission
}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={handleSettingsSectionChange}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetSlotAlertsEnabled={
notificationSettings.setSlotAlertsEnabled
}
onSetNotifyWhileViewing={
notificationSettings.setNotifyWhileViewing
}
onSetAllSlotAlertsEnabled={
notificationSettings.setAllSlotAlertsEnabled
}
onSetSoundForSlot={
notificationSettings.setSoundForSlot
}
section={settingsSection}
/>
</React.Suspense>
</div>
) : (
<div className="relative flex min-h-0 flex-1 overflow-visible">
{!isHuddleRoom ? (
<AppSidebar
activeCommunity={communitiesHook.activeCommunity}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={channelsErrorMessage}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount + dueReminderBadge}
addCommunityPrefill={addCommunityDialog.prefill}
isAddCommunityOpen={addCommunityDialog.open}
relayConnectionCard={relayConnectionCard}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isCreateChannelOpen={isCreateChannelOpen}
isHuddleCompanionOpen={isHuddleCompanionOpen}
isPresencePending={presenceSession.isPending}
onAddCommunity={(community) => {
const id = communitiesHook.addCommunity({
...community,
pubkey:
community.pubkey ?? identityQuery.data?.pubkey,
});
await goChannel(directMessage.id);
}}
onSelectAgents={() => void goAgents()}
onSelectChannel={handleSidebarChannelSelect}
onOpenSearchResult={handleOpenSearchResult}
searchChannels={channels}
searchFocusRequests={[
searchFocusRequest,
scopeSearchFocusRequest,
]}
onSelectHome={() => void goHome()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({
text: "",
emoji: "",
})
}
profile={profileQuery.data}
projectsOverviewActive={
location.pathname === "/projects"
}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
] ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
previewActivityChannelIds={unreadThreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={starChannel}
onUnstarChannel={unstarChannel}
/>
) : null}
<TerminalContextOverrideProvider
onChange={setTerminalContextOverride}
>
<AppShellChannelSurface
hasCommunityRail={hasCommunityRail}
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
terminal={
<TerminalBootstrap {...effectiveTerminalContext} />
}
handleSwitchCommunity(id);
}}
onAddCommunityOpenChange={
addCommunityDialog.onOpenChange
}
onNewMessage={goNewMessage}
onBackgroundClick={requestFocusedThreadClose}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={handleRemoveCommunity}
onSwitchCommunity={handleSwitchCommunity}
onCreateAgent={() => requestOpenCreateAgent()}
selfPresenceStatus={presenceSession.currentStatus}
communities={communitiesHook.communities}
onCreateChannel={handleCreateChannel}
onCreateForum={handleCreateForum}
onHideDm={handleHideDm}
onHuddleEnded={handleHuddleEnded}
onMarkAllChannelsRead={markAllChannelsRead}
onMarkChannelRead={markChannelRead}
onMarkChannelUnread={markChannelUnread}
onBrowseChannels={handleOpenBrowseChannels}
onOpenDm={async ({ pubkeys }) => {
const directMessage =
await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onSelectAgents={() => void goAgents()}
onSelectChannel={handleSidebarChannelSelect}
onOpenSearchResult={handleOpenSearchResult}
searchChannels={channels}
searchFocusRequests={[
searchFocusRequest,
scopeSearchFocusRequest,
]}
onSelectHome={() => void goHome()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({
text: "",
emoji: "",
})
}
profile={profileQuery.data}
projectsOverviewActive={
location.pathname === "/projects"
}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
] ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
previewActivityChannelIds={unreadThreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={starChannel}
onUnstarChannel={unstarChannel}
/>
) : null}
<TerminalContextOverrideProvider
onChange={setTerminalContextOverride}
>
<Outlet />
</AppShellChannelSurface>
</TerminalContextOverrideProvider>
{!isHuddleRoom ? (
<RelayConnectionOverlay
card={relayConnectionCard}
errorMessage={channelsErrorMessage}
hasCommunityRail={hasCommunityRail}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>
) : null}
</div>
)}
<RequestedAgentCreateDialogs />
<AgentManagementDialogs />
<AppShellOverlays
activeChannel={managedChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isCreatingBrowseChannel={
createChannelMutation.isPending ||
createForumMutation.isPending
}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseChannelCreate={handleBrowseChannelCreate}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
if (!open) {
setManagedChannelId(null);
<AppShellChannelSurface
hasCommunityRail={hasCommunityRail}
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
terminal={
<TerminalBootstrap {...effectiveTerminalContext} />
}
>
<Outlet />
</AppShellChannelSurface>
</TerminalContextOverrideProvider>
{!isHuddleRoom ? (
<RelayConnectionOverlay
card={relayConnectionCard}
errorMessage={channelsErrorMessage}
hasCommunityRail={hasCommunityRail}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>
) : null}
</div>
)}
<RequestedAgentCreateDialogs />
<AgentManagementDialogs />
<AppShellOverlays
activeChannel={managedChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isCreatingBrowseChannel={
createChannelMutation.isPending ||
createForumMutation.isPending
}
}}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
setManagedChannelId(null);
void goHome({ replace: true });
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
relayUrl={communitiesHook.activeCommunity?.relayUrl}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseChannelCreate={handleBrowseChannelCreate}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
if (!open) {
setManagedChannelId(null);
}
}}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
setManagedChannelId(null);
void goHome({ replace: true });
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
relayUrl={communitiesHook.activeCommunity?.relayUrl}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
</AppWorkflowEditorOverlayProvider>
</AppProfilePanelProvider>
</SidebarProvider>
</AppHuddleShell>

View File

@@ -0,0 +1,174 @@
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocation } from "@tanstack/react-router";
import { useChannelsQuery } from "@/features/channels/hooks";
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
import {
WorkflowEditorHost,
type WorkflowEditorTarget,
} from "@/features/workflows/ui/WorkflowEditorHost";
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
import { deleteWorkflow, triggerWorkflow } from "@/shared/api/tauriWorkflows";
import type { Workflow } from "@/shared/api/types";
import { WorkflowEditorOverlayProvider } from "@/shared/context/WorkflowEditorOverlayContext";
const INITIAL_PANE: WorkflowEditorPane = { type: "trigger" };
/** Rebuilds a target with a new pane without widening its discriminant. */
function withPane(
target: WorkflowEditorTarget,
pane: WorkflowEditorPane,
): WorkflowEditorTarget {
return target.mode === "create"
? { initialChannelId: target.initialChannelId, mode: "create", pane }
: { mode: target.mode, pane, workflowId: target.workflowId };
}
/**
* Hosts the shared workflow editor as an overlay owned by the app shell, so
* surfaces like channel settings can open a workflow without navigating away
* from the channel. The Workflows route keeps its own URL-addressable host —
* both render the same editor.
*/
export function AppWorkflowEditorOverlayProvider({
children,
}: Readonly<{ children: React.ReactNode }>) {
const queryClient = useQueryClient();
const channelsQuery = useChannelsQuery();
const memberChannels = React.useMemo(
() => (channelsQuery.data ?? []).filter((channel) => channel.isMember),
[channelsQuery.data],
);
const [editor, setEditor] = React.useState<WorkflowEditorTarget | null>(null);
const [workflowHint, setWorkflowHint] = React.useState<Workflow | undefined>(
undefined,
);
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const handleOpenWorkflow = React.useCallback(
(workflowId: string, workflow?: Workflow) => {
setWorkflowHint(workflow);
setEditor({ mode: "detail", pane: INITIAL_PANE, workflowId });
},
[],
);
const handleOpenNewWorkflow = React.useCallback((channelId?: string) => {
setWorkflowHint(undefined);
setEditor({
initialChannelId: channelId,
mode: "create",
pane: INITIAL_PANE,
});
}, []);
const closeEditor = React.useCallback(() => {
setEditor(null);
setWorkflowHint(undefined);
}, []);
// This editor belongs to the surface that opened it. If the route leaves that
// surface anyway, drop it rather than trailing the modal onto the next screen.
// The editor's own dirty-exit guard runs first, so unsaved work still prompts.
const { pathname } = useLocation();
const lastPathnameRef = React.useRef(pathname);
React.useEffect(() => {
if (lastPathnameRef.current === pathname) return;
lastPathnameRef.current = pathname;
closeEditor();
}, [closeEditor, pathname]);
const handleEditorPaneChange = React.useCallback(
(pane: WorkflowEditorPane) => {
setEditor((current) => (current ? withPane(current, pane) : current));
},
[],
);
const handleEditWorkflow = React.useCallback((workflowId: string) => {
setEditor({ mode: "edit", pane: INITIAL_PANE, workflowId });
}, []);
const handleDuplicateWorkflow = React.useCallback((workflowId: string) => {
setEditor({ mode: "duplicate", pane: INITIAL_PANE, workflowId });
}, []);
const triggerMutation = useMutation({
mutationFn: (workflowId: string) => triggerWorkflow(workflowId),
onSuccess: () => {
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === "workflow-runs",
});
},
});
const deleteMutation = useMutation({
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
onSuccess: () => {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
query.queryKey[0] === "workflows-all",
});
},
});
const triggerOne = triggerMutation.mutate;
const handleTriggerWorkflow = React.useCallback(
(workflowId: string) => triggerOne(workflowId),
[triggerOne],
);
const deleteOne = deleteMutation.mutateAsync;
const handleConfirmDelete = React.useCallback(
async (workflow: Workflow) => {
try {
await deleteOne(workflow.id);
setDeleteTarget(null);
closeEditor();
} catch {
// React Query stores the error; keep the confirmation and editor open.
}
},
[closeEditor, deleteOne],
);
return (
<WorkflowEditorOverlayProvider
onOpenNewWorkflow={handleOpenNewWorkflow}
onOpenWorkflow={handleOpenWorkflow}
>
{children}
<WorkflowEditorHost
channels={memberChannels}
editor={editor}
onClose={closeEditor}
onDeleteWorkflow={setDeleteTarget}
onDuplicateWorkflow={handleDuplicateWorkflow}
onEditWorkflow={handleEditWorkflow}
onEditorPaneChange={handleEditorPaneChange}
onTriggerWorkflow={handleTriggerWorkflow}
workflowHint={workflowHint}
/>
<WorkflowDeleteDialog
error={
deleteMutation.error instanceof Error
? deleteMutation.error.message
: null
}
isPending={deleteMutation.isPending}
onConfirm={handleConfirmDelete}
onOpenChange={(open) => {
if (!open) {
deleteMutation.reset();
setDeleteTarget(null);
}
}}
open={deleteTarget !== null}
workflow={deleteTarget}
/>
</WorkflowEditorOverlayProvider>
);
}

View File

@@ -169,6 +169,66 @@ export function useAppNavigation() {
params: {
workflowId,
},
search: { pane: "trigger" },
state: { workflowEditorHasOrigin: true },
},
behavior,
),
[commitNavigation],
);
const goNewWorkflow = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows",
search: { pane: "trigger", view: "create" },
state: { workflowEditorHasOrigin: true },
},
behavior,
),
[commitNavigation],
);
const goNewWorkflowForChannel = React.useCallback(
(channelId: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows",
search: {
channel: channelId,
pane: "trigger",
view: "create",
},
state: { workflowEditorHasOrigin: true },
},
behavior,
),
[commitNavigation],
);
const goEditWorkflow = React.useCallback(
(workflowId: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows/$workflowId",
params: { workflowId },
search: { pane: "trigger", view: "edit" },
state: { workflowEditorHasOrigin: true },
},
behavior,
),
[commitNavigation],
);
const goDuplicateWorkflow = React.useCallback(
(workflowId: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows/$workflowId",
params: { workflowId },
search: { pane: "trigger", view: "duplicate" },
state: { workflowEditorHasOrigin: true },
},
behavior,
),
@@ -330,9 +390,13 @@ export function useAppNavigation() {
closeWorkflowDetail,
goAgents,
goChannel,
goDuplicateWorkflow,
goEditWorkflow,
goForumPost,
goHome,
goNewMessage,
goNewWorkflow,
goNewWorkflowForChannel,
goProject,
goProjects,
goPulse,

View File

@@ -1,15 +1,36 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen";
import {
type WorkflowEditorRoute,
WorkflowsScreen,
} from "@/features/workflows/ui/WorkflowsScreen";
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
type WorkflowsRouteScreenProps = {
selectedWorkflowId: string | null;
editor?: WorkflowEditorRoute | null;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
};
export function WorkflowsRouteScreen({
selectedWorkflowId,
editor = null,
onEditorPaneChange,
}: WorkflowsRouteScreenProps) {
const { closeWorkflowDetail, goWorkflow } = useAppNavigation();
const {
goDuplicateWorkflow,
goEditWorkflow,
goNewWorkflow,
goWorkflow,
goWorkflows,
} = useAppNavigation();
const closeEditor = React.useCallback(() => {
if (editor?.hasOrigin) {
window.history.back();
return;
}
void goWorkflows({ replace: true });
}, [editor?.hasOrigin, goWorkflows]);
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);
@@ -17,11 +38,21 @@ export function WorkflowsRouteScreen({
return (
<WorkflowsScreen
channels={memberChannels}
onCloseWorkflow={closeWorkflowDetail}
onSelectWorkflow={(workflowId) => {
editor={editor}
onCloseEditor={closeEditor}
onCreateWorkflow={() => {
void goNewWorkflow();
}}
onDuplicateWorkflow={(workflowId) => {
void goDuplicateWorkflow(workflowId);
}}
onEditWorkflow={(workflowId) => {
void goEditWorkflow(workflowId);
}}
onViewWorkflow={(workflowId) => {
void goWorkflow(workflowId);
}}
selectedWorkflowId={selectedWorkflowId}
onEditorPaneChange={onEditorPaneChange}
/>
);
}

View File

@@ -0,0 +1,6 @@
import * as React from "react";
export const LazyWorkflowsRouteScreen = React.lazy(async () => {
const module = await import("./WorkflowsRouteScreen");
return { default: module.WorkflowsRouteScreen };
});

View File

@@ -1,25 +1,62 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, useLocation } from "@tanstack/react-router";
import {
parseWorkflowEditorPane,
serializeWorkflowEditorPane,
} from "@/features/workflows/ui/workflowEditorPane";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen";
export const Route = createFileRoute("/workflows/$workflowId")({
component: WorkflowDetailRouteComponent,
component: WorkflowRouteComponent,
validateSearch: (search: Record<string, unknown>) => ({
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
view:
search.view === "edit" || search.view === "duplicate"
? search.view
: undefined,
}),
});
const WorkflowsRouteScreen = React.lazy(async () => {
const module = await import("./WorkflowsRouteScreen");
return { default: module.WorkflowsRouteScreen };
});
function WorkflowDetailRouteComponent() {
function WorkflowRouteComponent() {
usePreviewFeatureWarning("workflows");
const navigate = Route.useNavigate();
const location = useLocation();
const { workflowId } = Route.useParams();
const { pane, view } = Route.useSearch();
const hasOrigin =
(location.state as { workflowEditorHasOrigin?: unknown } | undefined)
?.workflowEditorHasOrigin === true;
const editor: import("@/features/workflows/ui/WorkflowsScreen").WorkflowEditorRoute =
{
hasOrigin,
mode:
view === "duplicate"
? "duplicate"
: view === "edit"
? "edit"
: "detail",
pane: parseWorkflowEditorPane(pane),
workflowId,
};
return (
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsRouteScreen selectedWorkflowId={workflowId} />
<LazyWorkflowsRouteScreen
editor={editor}
onEditorPaneChange={(nextPane) => {
void navigate({
replace: true,
resetScroll: false,
search: {
pane: serializeWorkflowEditorPane(nextPane),
view,
},
});
}}
/>
</React.Suspense>
);
}

View File

@@ -1,23 +1,57 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, useLocation } from "@tanstack/react-router";
import {
parseWorkflowEditorPane,
serializeWorkflowEditorPane,
} from "@/features/workflows/ui/workflowEditorPane";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen";
export const Route = createFileRoute("/workflows")({
component: WorkflowsRouteComponent,
});
const WorkflowsRouteScreen = React.lazy(async () => {
const module = await import("./WorkflowsRouteScreen");
return { default: module.WorkflowsRouteScreen };
validateSearch: (search: Record<string, unknown>) => ({
channel: typeof search.channel === "string" ? search.channel : undefined,
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
view: search.view === "create" ? search.view : undefined,
}),
});
function WorkflowsRouteComponent() {
usePreviewFeatureWarning("workflows");
const navigate = Route.useNavigate();
const location = useLocation();
const { channel, pane, view } = Route.useSearch();
const hasOrigin =
(location.state as { workflowEditorHasOrigin?: unknown } | undefined)
?.workflowEditorHasOrigin === true;
return (
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsRouteScreen selectedWorkflowId={null} />
<LazyWorkflowsRouteScreen
editor={
view === "create"
? {
hasOrigin,
initialChannelId: channel,
mode: "create",
pane: parseWorkflowEditorPane(pane),
}
: null
}
onEditorPaneChange={(nextPane) => {
void navigate({
replace: true,
resetScroll: false,
search: {
channel,
pane: serializeWorkflowEditorPane(nextPane),
view,
},
});
}}
/>
</React.Suspense>
);
}

View File

@@ -5,6 +5,7 @@ import {
DoorClosed,
DoorOpen,
Trash2,
Workflow as WorkflowIcon,
} from "lucide-react";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
@@ -21,11 +22,15 @@ import {
useUpdateChannelMutation,
} from "@/features/channels/hooks";
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelWorkflowsQuery } from "@/features/workflows/hooks";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import type { Channel, ChannelMember } from "@/shared/api/types";
import type { Channel, ChannelMember, Workflow } from "@/shared/api/types";
import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext";
import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
@@ -55,6 +60,7 @@ import {
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { ChannelCanvas } from "./ChannelCanvas";
import { ChannelWorkflowsSection } from "./ChannelWorkflowsSection";
import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
@@ -101,15 +107,24 @@ export function ChannelManagementSheet({
transparentChrome = false,
}: ChannelManagementSheetProps) {
const { isDark } = useTheme();
const { goNewWorkflowForChannel, goWorkflow } = useAppNavigation();
const {
openNewWorkflow: openNewWorkflowOverlay,
openWorkflow: openWorkflowOverlay,
} = useWorkflowEditorOverlay();
const isSplitLayout = layout === "split";
const auxiliaryPanelMode = getAuxiliaryPanelMode(
isSplitLayout,
!isSplitLayout,
);
const channelId = channel?.id ?? null;
const workflowsEnabled = useFeatureEnabled("workflows");
const detailsQuery = useChannelDetailsQuery(channelId, open);
const membersQuery = useChannelMembersQuery(channelId, open);
const canvasQuery = useCanvasQuery(channelId, channelId !== null && open);
const workflowsQuery = useChannelWorkflowsQuery(
workflowsEnabled && channelId !== null && open ? channelId : null,
);
const updateChannelDetailsMutation = useUpdateChannelMutation(channelId);
const archiveChannelMutation = useArchiveChannelMutation(channelId);
const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId);
@@ -160,9 +175,11 @@ export function ChannelManagementSheet({
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false);
const [hasUserEditedChannelDraft, setHasUserEditedChannelDraft] =
React.useState(false);
const [activeView, setActiveView] = React.useState<"summary" | "canvas">(
"summary",
);
const [activeView, setActiveView] = React.useState<
"summary" | "canvas" | "workflows"
>("summary");
const visibleActiveView =
workflowsEnabled || activeView !== "workflows" ? activeView : "summary";
const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } =
useDeferredModalOpen();
@@ -237,6 +254,33 @@ export function ChannelManagementSheet({
onOpenChange(next);
}
// Workflows open as a modal above the channel settings Workflows view. Keep
// that view mounted behind the editor so every completed close path (clean,
// dirty-discard, or create cancel) returns to the exact surface that opened
// it. The navigation fallbacks still close the sheet before changing routes;
// canonical /workflows deep links stay unchanged either way.
function handleOpenWorkflow(workflow: Workflow) {
if (openWorkflowOverlay) {
openWorkflowOverlay(workflow.id, workflow);
return;
}
handlePanelOpenChange(false);
void goWorkflow(workflow.id);
}
function handleCreateWorkflow() {
if (!channelId) return;
if (openNewWorkflowOverlay) {
openNewWorkflowOverlay(channelId);
return;
}
handlePanelOpenChange(false);
void goNewWorkflowForChannel(channelId);
}
const currentVisibility = detail?.visibility ?? channel.visibility;
const currentTtlSeconds = detail?.ttlSeconds ?? null;
const nextVisibility: "open" | "private" = isPrivateDraft
@@ -338,7 +382,7 @@ export function ChannelManagementSheet({
onPointerDownOutside={(event) => event.preventDefault()}
>
<ChannelManagementPanelContent
activeView={activeView}
activeView={visibleActiveView}
archiveChannelMutation={archiveChannelMutation}
canEditNarrative={canEditNarrative}
canEditChannel={canEditChannel}
@@ -349,6 +393,10 @@ export function ChannelManagementSheet({
canvasQuery={canvasQuery}
channelId={channelId}
currentPubkey={currentPubkey}
workflowsEnabled={workflowsEnabled}
workflowsQuery={workflowsQuery}
onCreateWorkflow={handleCreateWorkflow}
onOpenWorkflow={handleOpenWorkflow}
deleteChannelMutation={deleteChannelMutation}
detailsError={detailsQuery.error}
handleDeleteChannel={handleDeleteChannel}
@@ -386,7 +434,7 @@ export function ChannelManagementSheet({
data-testid="channel-management-sheet"
>
<ChannelManagementPanelContent
activeView={activeView}
activeView={visibleActiveView}
archiveChannelMutation={archiveChannelMutation}
canEditNarrative={canEditNarrative}
canEditChannel={canEditChannel}
@@ -397,6 +445,10 @@ export function ChannelManagementSheet({
canvasQuery={canvasQuery}
channelId={channelId}
currentPubkey={currentPubkey}
workflowsEnabled={workflowsEnabled}
workflowsQuery={workflowsQuery}
onCreateWorkflow={handleCreateWorkflow}
onOpenWorkflow={handleOpenWorkflow}
deleteChannelMutation={deleteChannelMutation}
detailsError={detailsQuery.error}
handleDeleteChannel={handleDeleteChannel}
@@ -569,7 +621,7 @@ type ChannelMutation<TArgs = void> = {
};
type ChannelManagementPanelContentProps = {
activeView: "summary" | "canvas";
activeView: "summary" | "canvas" | "workflows";
archiveChannelMutation: ChannelMutation;
canEditChannel: boolean;
canEditNarrative: boolean;
@@ -580,6 +632,15 @@ type ChannelManagementPanelContentProps = {
canvasQuery: { isLoading: boolean };
channelId: string | null;
currentPubkey?: string;
workflowsEnabled: boolean;
workflowsQuery: {
data?: Workflow[];
error: unknown;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
onCreateWorkflow: () => void;
onOpenWorkflow: (workflow: Workflow) => void;
deleteChannelMutation: ChannelMutation;
detailsError: unknown;
handleDeleteChannel: () => Promise<void>;
@@ -598,7 +659,9 @@ type ChannelManagementPanelContentProps = {
onOpenMembers?: () => void;
onOpenChange: (open: boolean) => void;
resolvedChannel: Channel;
setActiveView: React.Dispatch<React.SetStateAction<"summary" | "canvas">>;
setActiveView: React.Dispatch<
React.SetStateAction<"summary" | "canvas" | "workflows">
>;
unarchiveChannelMutation: ChannelMutation;
};
@@ -614,6 +677,10 @@ function ChannelManagementPanelContent({
canvasQuery,
channelId,
currentPubkey,
workflowsEnabled,
workflowsQuery,
onCreateWorkflow,
onOpenWorkflow,
deleteChannelMutation,
detailsError,
handleDeleteChannel,
@@ -663,12 +730,18 @@ function ChannelManagementPanelContent({
backButtonTestId="channel-management-back"
mode={mode}
onBack={
activeView === "canvas" ? () => setActiveView("summary") : undefined
activeView !== "summary"
? () => setActiveView("summary")
: undefined
}
>
<DialogPrimitive.Title asChild>
<AuxiliaryPanelTitle>
{activeView === "canvas" ? "Canvas" : "Channel Settings"}
{activeView === "canvas"
? "Canvas"
: activeView === "workflows"
? "Workflows"
: "Channel Settings"}
</AuxiliaryPanelTitle>
</DialogPrimitive.Title>
</AuxiliaryPanelHeaderGroup>
@@ -749,14 +822,45 @@ function ChannelManagementPanelContent({
</FieldGroup>
{canOpenCanvas ? (
<div className="space-y-3">
<IngressRow
description={canvasPreview}
helpText="Use the canvas as a shared space for notes, plans, and other channel information."
icon={BookOpenText}
label="Canvas"
onClick={() => setActiveView("canvas")}
testId="channel-canvas-ingress"
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
/>
{workflowsEnabled ? (
<IngressRow
description={
workflowsQuery.isLoading
? undefined
: `${workflowsQuery.data?.length ?? 0} workflow${workflowsQuery.data?.length === 1 ? "" : "s"}`
}
icon={WorkflowIcon}
label="Workflows"
onClick={() => setActiveView("workflows")}
testId="channel-workflows-ingress"
trailing={
workflowsQuery.isLoading ? "Loading..." : undefined
}
/>
) : null}
</div>
) : workflowsEnabled ? (
<IngressRow
description={canvasPreview}
helpText="Use the canvas as a shared space for notes, plans, and other channel information."
icon={BookOpenText}
label="Canvas"
onClick={() => setActiveView("canvas")}
testId="channel-canvas-ingress"
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
description={
workflowsQuery.isLoading
? undefined
: `${workflowsQuery.data?.length ?? 0} workflow${workflowsQuery.data?.length === 1 ? "" : "s"}`
}
icon={WorkflowIcon}
label="Workflows"
onClick={() => setActiveView("workflows")}
testId="channel-workflows-ingress"
trailing={workflowsQuery.isLoading ? "Loading..." : undefined}
/>
) : null}
@@ -871,7 +975,7 @@ function ChannelManagementPanelContent({
</p>
) : null}
</div>
) : (
) : activeView === "canvas" ? (
<div data-testid="channel-canvas-section">
<ChannelCanvas
canEdit={canEditNarrative}
@@ -879,7 +983,16 @@ function ChannelManagementPanelContent({
isArchived={isArchived}
/>
</div>
)}
) : activeView === "workflows" && workflowsEnabled ? (
<ChannelWorkflowsSection
error={workflowsQuery.error}
loading={workflowsQuery.isLoading}
onCreate={onCreateWorkflow}
onOpen={onOpenWorkflow}
onRetry={() => void workflowsQuery.refetch()}
workflows={workflowsQuery.data ?? []}
/>
) : null}
</AuxiliaryPanelBody>
</AuxiliaryPanelContext.Provider>
);

View File

@@ -0,0 +1,77 @@
import { Plus, Workflow as WorkflowIcon } from "lucide-react";
import type { Workflow } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { FieldGroup } from "./ChannelManagementSheetRows";
export function ChannelWorkflowsSection({
error,
loading,
onCreate,
onOpen,
onRetry,
workflows,
}: {
error: unknown;
loading: boolean;
onCreate: () => void;
onOpen: (workflow: Workflow) => void;
onRetry: () => void;
workflows: Workflow[];
}) {
return (
<div className="space-y-4 pt-3" data-testid="channel-workflows-section">
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Loading workflows...
</p>
) : error instanceof Error ? (
<div className="space-y-3 rounded-xl border border-destructive/30 bg-destructive/10 p-4">
<p className="text-sm text-destructive">{error.message}</p>
<Button onClick={onRetry} size="sm" variant="outline">
Retry
</Button>
</div>
) : workflows.length > 0 ? (
<FieldGroup testId="channel-workflows-list">
{workflows.map((workflow) => (
<button
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
data-testid={`channel-workflow-${workflow.id}`}
key={workflow.id}
onClick={() => onOpen(workflow)}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<WorkflowIcon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{workflow.name}
</span>
<span className="block text-xs capitalize text-muted-foreground">
{workflow.status}
</span>
</span>
</button>
))}
</FieldGroup>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
No workflows in this channel yet.
</p>
)}
<Button
className="w-full"
data-testid="channel-workflows-new"
onClick={onCreate}
type="button"
variant="outline"
>
<Plus />
New workflow
</Button>
</div>
);
}

View File

@@ -1,45 +1,137 @@
import { Check, ChevronsUpDown, Search } from "lucide-react";
import {
Asterisk,
Check,
ChevronDown,
Hash,
Lock,
MessageSquareMore,
Search,
} from "lucide-react";
import * as React from "react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { PortalledScrollArea } from "@/shared/ui/PortalledScrollArea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
function formatChannelLabel(ch: Channel): string {
return `${ch.name} · ${ch.channelType} · ${ch.visibility}`;
function ChannelPrivacyIcon({ channel }: { channel: Channel }) {
const Icon =
channel.channelType === "dm"
? MessageSquareMore
: channel.visibility === "private"
? Lock
: Hash;
return (
<Icon aria-hidden className="h-5 w-5 shrink-0 text-muted-foreground/60" />
);
}
type ChannelComboboxProps = {
allowEmpty?: boolean;
ariaLabel?: string;
channels: Channel[];
defaultOpen?: boolean;
disabled?: boolean;
emptyLabel?: string;
id?: string;
isChannelDisabled?: (channel: Channel) => boolean;
onAutoOpen?: () => void;
onChange: (value: string) => void;
readOnly?: boolean;
readOnlyTooltip?: string;
required?: boolean;
variant?: "header" | "field";
value: string;
};
export function ChannelCombobox({
allowEmpty = false,
ariaLabel = "Channel",
channels,
defaultOpen = false,
disabled,
emptyLabel = "Choose a channel",
id,
isChannelDisabled,
onAutoOpen,
onChange,
readOnly = false,
readOnlyTooltip = "The channel can't be changed after a workflow is created.",
required = false,
variant = "header",
value,
}: ChannelComboboxProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const autoOpenHandledRef = React.useRef(false);
React.useEffect(() => {
if (!defaultOpen || autoOpenHandledRef.current) return;
// Let the pointer interaction that mounted the containing dialog finish
// before installing Radix's outside-interaction listeners.
const frame = window.requestAnimationFrame(() => {
autoOpenHandledRef.current = true;
setOpen(true);
onAutoOpen?.();
});
return () => window.cancelAnimationFrame(frame);
}, [defaultOpen, onAutoOpen]);
const listboxId = `${id ?? "channel"}-listbox`;
const selected = channels.find((c) => c.id === value);
const currentPubkey = useIdentityQuery().data?.pubkey;
const dmParticipantPubkeys = React.useMemo(() => {
const visibleDmChannels = open
? channels.filter((channel) => channel.channelType === "dm")
: selected?.channelType === "dm"
? [selected]
: [];
return visibleDmChannels.flatMap((channel) =>
channel.participantPubkeys.filter(
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
),
);
}, [channels, currentPubkey, open, selected]);
const dmProfiles = useUsersBatchQuery(dmParticipantPubkeys, {
enabled: dmParticipantPubkeys.length > 0,
}).data?.profiles;
const channelLabels = React.useMemo(
() =>
new Map(
channels.map((channel) => [
channel.id,
resolveChannelDisplayLabel(channel, currentPubkey, dmProfiles),
]),
),
[channels, currentPubkey, dmProfiles],
);
const filtered = React.useMemo(() => {
if (!query) return channels;
const q = query.toLowerCase();
return channels.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(channelLabels.get(c.id) ?? c.name).toLowerCase().includes(q) ||
c.channelType?.toLowerCase().includes(q) ||
c.id.toLowerCase().includes(q),
);
}, [channels, query]);
}, [channelLabels, channels, query]);
const selectable = React.useMemo(
() => filtered.filter((channel) => !isChannelDisabled?.(channel)),
[filtered, isChannelDisabled],
);
const highlightedChannel = selectable[highlightedIndex];
const highlightedOptionId = highlightedChannel
? `${listboxId}-option-${highlightedChannel.id}`
: undefined;
function handleOpenChange(next: boolean) {
setOpen(next);
@@ -55,22 +147,24 @@ export function ChannelCombobox({
}
function handleKeyDown(e: React.KeyboardEvent) {
if (filtered.length === 0) return;
if (selectable.length === 0) return;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % filtered.length);
setHighlightedIndex((i) => (i + 1) % selectable.length);
break;
}
case "ArrowUp": {
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length);
setHighlightedIndex(
(i) => (i - 1 + selectable.length) % selectable.length,
);
break;
}
case "Enter": {
e.preventDefault();
const target = filtered[highlightedIndex];
const target = selectable[highlightedIndex];
if (target) selectChannel(target.id);
break;
}
@@ -82,13 +176,54 @@ export function ChannelCombobox({
}
}
const selectedLabel = selected
? (channelLabels.get(selected.id) ?? selected.name)
: value
? "Unavailable channel"
: emptyLabel;
if (readOnly) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-disabled="true"
aria-label={`${ariaLabel}: ${selectedLabel}. Read only.`}
className={cn(
"flex w-full cursor-default items-center rounded-lg px-3 py-2 text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
variant === "header" &&
"justify-center border-0 bg-transparent text-lg font-semibold",
variant === "field" && "text-sm font-normal",
)}
id={id}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
{selected ? <ChannelPrivacyIcon channel={selected} /> : null}
<span className="truncate">{selectedLabel}</span>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">{readOnlyTooltip}</TooltipContent>
</Tooltip>
);
}
return (
<Popover onOpenChange={handleOpenChange} open={open}>
<PopoverTrigger asChild>
<button
aria-controls={listboxId}
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
aria-required={required}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 text-sm shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"group flex w-full items-center rounded-lg px-3 py-2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
variant === "header" &&
"justify-center border-0 bg-transparent text-lg font-semibold",
variant === "field" &&
"justify-between border border-input/40 bg-background text-sm font-normal",
!selected && "text-muted-foreground",
)}
disabled={disabled}
@@ -96,19 +231,35 @@ export function ChannelCombobox({
role="combobox"
type="button"
>
<span className="truncate">
{selected ? formatChannelLabel(selected) : "Select a channel..."}
<span
className={cn(
"flex min-w-0 items-center gap-2",
variant === "header" && "justify-center",
)}
>
{selected ? (
<ChannelPrivacyIcon channel={selected} />
) : required && !value ? (
<Asterisk aria-hidden className="h-5 w-5 shrink-0 text-primary" />
) : null}
<span className="truncate">{selectedLabel}</span>
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
<ChevronDown className="ml-1 h-5 w-5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) p-0"
portalled={variant === "field"}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
aria-activedescendant={highlightedOptionId}
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={open}
aria-label={`Search ${ariaLabel.toLowerCase()}s`}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
@@ -120,44 +271,81 @@ export function ChannelCombobox({
}}
onKeyDown={handleKeyDown}
placeholder="Search channels..."
role="combobox"
spellCheck={false}
value={query}
/>
</div>
<div className="max-h-60 overflow-y-auto p-1">
<PortalledScrollArea
aria-label={`${ariaLabel} options`}
className="max-h-60 overflow-y-auto p-1"
data-testid="channel-combobox-list"
id={listboxId}
role="listbox"
>
{allowEmpty && !query ? (
<button
aria-selected={!value}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
!value && "bg-accent/50",
)}
onClick={() => selectChannel("")}
role="option"
type="button"
>
<span className="h-5 w-5 shrink-0" />
<span className="truncate">{emptyLabel}</span>
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
!value ? "opacity-100" : "opacity-0",
)}
/>
</button>
) : null}
{filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
No channels found.
</p>
) : (
filtered.map((channel, index) => (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
channel.id === value && "bg-accent/50",
index === highlightedIndex &&
"bg-accent text-accent-foreground",
)}
key={channel.id}
onClick={() => selectChannel(channel.id)}
type="button"
>
<Check
filtered.map((channel) => {
const optionDisabled = isChannelDisabled?.(channel) ?? false;
return (
<button
aria-selected={channel.id === value}
className={cn(
"h-4 w-4 shrink-0",
channel.id === value ? "opacity-100" : "opacity-0",
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent disabled:hover:text-foreground",
channel.id === value && "bg-accent/50",
channel.id === selectable[highlightedIndex]?.id &&
!optionDisabled &&
"bg-accent text-accent-foreground",
)}
/>
<span className="truncate">
{channel.name}{" "}
<span className="text-muted-foreground">
· {channel.channelType} · {channel.visibility}
disabled={optionDisabled}
id={`${listboxId}-option-${channel.id}`}
key={channel.id}
onClick={() => selectChannel(channel.id)}
role="option"
type="button"
>
<ChannelPrivacyIcon channel={channel} />
<span className="truncate">
{channelLabels.get(channel.id) ?? channel.name}
{channel.channelType === "dm" ? (
<span className="text-muted-foreground"> · dm</span>
) : null}
</span>
</span>
</button>
))
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
channel.id === value ? "opacity-100" : "opacity-0",
)}
/>
</button>
);
})
)}
</div>
</PortalledScrollArea>
</PopoverContent>
</Popover>
);

View File

@@ -1,23 +0,0 @@
import type { Channel } from "@/shared/api/types";
import { WorkflowDialog } from "./WorkflowDialog";
type CreateWorkflowDialogProps = {
channels: Channel[];
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function CreateWorkflowDialog({
channels,
onOpenChange,
open,
}: CreateWorkflowDialogProps) {
return (
<WorkflowDialog
channels={channels}
mode="create"
onOpenChange={onOpenChange}
open={open}
/>
);
}

View File

@@ -0,0 +1,169 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import {
CRON_FIELD_DEFINITIONS,
cronExpressionFromFields,
cronFieldsFromExpression,
cronFieldsFromPaste,
normalizeCronExpression,
validateCronFields,
} from "./cronExpression";
import type { CronFields } from "./cronExpression";
export function CronExpressionInput({
disabled,
onChange,
value,
}: {
disabled?: boolean;
onChange: (value: string) => void;
value: string;
}) {
const [fields, setFields] = React.useState<CronFields>(() =>
cronFieldsFromExpression(value),
);
const [pasteError, setPasteError] = React.useState<string | null>(null);
const inputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
const localValue = React.useRef(normalizeCronExpression(value));
const validationErrors = validateCronFields(fields);
const firstError = pasteError ?? validationErrors.find(Boolean) ?? null;
const messageId = "wf-trigger-cron-message";
React.useEffect(() => {
const nextValue = normalizeCronExpression(value);
if (nextValue !== localValue.current) {
setFields(cronFieldsFromExpression(value));
localValue.current = nextValue;
setPasteError(null);
}
}, [value]);
const commitFields = (nextFields: CronFields) => {
const expression = cronExpressionFromFields(nextFields);
setFields(nextFields);
setPasteError(null);
localValue.current = normalizeCronExpression(expression);
onChange(expression);
};
const focusField = (index: number) => {
inputRefs.current[index]?.focus();
inputRefs.current[index]?.select();
};
return (
<fieldset className="space-y-1.5">
<legend className="text-xs font-medium text-muted-foreground">
Cron expression
</legend>
<div aria-describedby={messageId} className="space-y-1">
<div className="grid grid-cols-5 gap-px px-px">
{CRON_FIELD_DEFINITIONS.map((definition, index) => (
<label
className={cn(
"min-w-0 truncate px-1 text-center text-2xs text-muted-foreground",
validationErrors[index] && "text-destructive",
)}
htmlFor={`wf-trigger-cron-${definition.label.toLowerCase()}`}
key={definition.label}
>
{definition.label}
</label>
))}
</div>
<div
className={cn(
"grid grid-cols-5 overflow-hidden rounded-lg border border-input/50 bg-background",
"focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
disabled && "opacity-50",
)}
>
{CRON_FIELD_DEFINITIONS.map((definition, index) => (
<input
aria-describedby={messageId}
aria-invalid={Boolean(validationErrors[index])}
aria-label={definition.label}
autoCapitalize="characters"
autoCorrect="off"
className={cn(
"h-11 min-w-0 border-l border-input/50 bg-transparent px-1 text-center font-mono text-sm text-foreground outline-hidden first:border-l-0",
"placeholder:text-muted-foreground/60 focus:z-10 focus:bg-muted/35",
"disabled:cursor-not-allowed",
validationErrors[index] &&
"bg-destructive/5 text-destructive focus:bg-destructive/10",
)}
disabled={disabled}
id={`wf-trigger-cron-${definition.label.toLowerCase()}`}
key={definition.label}
onChange={(event) => {
const nextFields = [...fields] as CronFields;
nextFields[index] = event.target.value.replace(/\s/g, "");
commitFields(nextFields);
}}
onKeyDown={(event) => {
const input = event.currentTarget;
if (event.key === " " && index < fields.length - 1) {
event.preventDefault();
focusField(index + 1);
} else if (
event.key === "Backspace" &&
!input.value &&
index > 0
) {
event.preventDefault();
focusField(index - 1);
} else if (
event.key === "ArrowLeft" &&
input.selectionStart === 0 &&
index > 0
) {
event.preventDefault();
focusField(index - 1);
} else if (
event.key === "ArrowRight" &&
input.selectionStart === input.value.length &&
index < fields.length - 1
) {
event.preventDefault();
focusField(index + 1);
}
}}
onPaste={(event) => {
const pastedValue =
event.clipboardData.getData("text/plain") ||
event.clipboardData.getData("text");
if (!/\s/.test(pastedValue.trim())) return;
event.preventDefault();
const result = cronFieldsFromPaste(pastedValue);
if (!result.ok) {
setPasteError(result.error);
return;
}
commitFields(result.fields);
}}
placeholder="*"
ref={(element) => {
inputRefs.current[index] = element;
}}
spellCheck={false}
value={fields[index]}
/>
))}
</div>
</div>
<p
className={cn(
"text-xs text-muted-foreground",
firstError && "text-destructive",
)}
id={messageId}
role={firstError ? "alert" : undefined}
>
{firstError ??
"UTC · Paste all 5 fields, or use wildcards, lists, ranges, and steps."}
</p>
</fieldset>
);
}

View File

@@ -29,9 +29,8 @@ import {
type WorkflowCardProps = {
workflow: Workflow;
channelName?: string;
isActive?: boolean;
isTogglingEnabled?: boolean;
onSelect: (workflowId: string) => void;
onView: (workflow: Workflow) => void;
onTrigger: (workflowId: string) => void;
onToggleEnabled: (workflow: Workflow) => void;
onEdit: (workflow: Workflow) => void;
@@ -81,9 +80,8 @@ function StatusBadge({ status }: { status: Workflow["status"] }) {
export function WorkflowCard({
workflow,
channelName,
isActive = false,
isTogglingEnabled = false,
onSelect,
onView,
onTrigger,
onToggleEnabled,
onEdit,
@@ -102,14 +100,13 @@ export function WorkflowCard({
return (
<div
className={cn(
"group relative min-h-60 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/50 p-5 text-left text-foreground shadow-xs transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/65 hover:shadow-md",
isActive && "border-primary/50 bg-primary/5 ring-1 ring-primary/30",
"group relative min-h-60 w-full overflow-hidden rounded-2xl bg-muted/50 p-5 text-left text-foreground shadow-xs transition-colors hover:bg-muted/65",
)}
data-testid={`workflow-card-${workflow.id}`}
>
<button
className="absolute inset-0 z-0 rounded-2xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={() => onSelect(workflow.id)}
onClick={() => onView(workflow)}
type="button"
>
<span className="sr-only">View {workflow.name}</span>
@@ -159,7 +156,7 @@ export function WorkflowCard({
{triggerSummary}
</p>
) : null}
<h3 className="mt-1 line-clamp-2 text-xl font-bold leading-tight tracking-tight">
<h3 className="mt-1 line-clamp-4 text-xl font-bold leading-tight tracking-tight">
{workflow.name}
</h3>
{description ? (

View File

@@ -1,7 +1,6 @@
import type { Workflow } from "@/shared/api/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
@@ -12,20 +11,29 @@ import {
import { Button } from "@/shared/ui/button";
type WorkflowDeleteDialogProps = {
error?: string | null;
isPending?: boolean;
open: boolean;
workflow: Workflow | null;
onConfirm: (workflow: Workflow) => void;
onConfirm: (workflow: Workflow) => Promise<void>;
onOpenChange: (open: boolean) => void;
};
export function WorkflowDeleteDialog({
error,
isPending = false,
open,
workflow,
onConfirm,
onOpenChange,
}: WorkflowDeleteDialogProps) {
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialog
onOpenChange={(nextOpen) => {
if (!isPending) onOpenChange(nextOpen);
}}
open={open}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
@@ -34,26 +42,35 @@ export function WorkflowDeleteDialog({
? `Delete "${workflow.name}". This will stop all future triggers and remove the workflow permanently.`
: "Delete this workflow."}
</AlertDialogDescription>
{error ? (
<p
aria-live="polite"
className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
Couldn’t delete workflow. {error} Try again or cancel to keep
editing.
</p>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
<Button disabled={isPending} type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
if (workflow) {
onConfirm(workflow);
}
}}
type="button"
variant="destructive"
>
Delete
</Button>
</AlertDialogAction>
<Button
disabled={!workflow || isPending}
onClick={() => {
if (workflow) {
void onConfirm(workflow);
}
}}
type="button"
variant="destructive"
>
{isPending ? "Deleting…" : "Delete"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -20,14 +20,18 @@ import {
type WorkflowDetailPanelProps = {
workflowId: string;
onClose: () => void;
onEdit: (workflow: Workflow) => void;
onClose?: () => void;
onEdit?: (workflow: Workflow) => void;
showDefinition?: boolean;
showHeader?: boolean;
};
export function WorkflowDetailPanel({
workflowId,
onClose,
onEdit,
showDefinition = true,
showHeader = true,
}: WorkflowDetailPanelProps) {
const workflowQuery = useWorkflowQuery(workflowId);
const runsQuery = useWorkflowRunsQuery(workflowId);
@@ -66,66 +70,76 @@ export function WorkflowDetailPanel({
return (
<div
className="flex h-full flex-col border-l bg-background pt-4"
className={
showHeader
? "flex h-full flex-col border-l bg-background pt-4"
: "flex h-full flex-col"
}
data-testid="workflow-detail-panel"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
{workflow ? (
<h3 className="truncate text-sm font-semibold">
{workflow.name}
</h3>
) : (
<Skeleton className="h-4 w-36" />
)}
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
{showHeader ? (
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
{workflow ? (
<h3 className="truncate text-sm font-semibold">
{workflow.name}
</h3>
) : (
<Skeleton className="h-4 w-36" />
)}
{workflowStatus ? (
<RunStatusBadge status={workflowStatus} />
) : null}
</div>
{workflowDescription ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{workflowDescription}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-full max-w-64" />
) : null}
{triggerSummary ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{triggerSummary}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-40" />
) : null}
</div>
{workflowDescription ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{workflowDescription}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-full max-w-64" />
) : null}
{triggerSummary ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{triggerSummary}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-40" />
) : null}
</div>
<div className="flex items-center gap-1">
{workflow ? (
<div className="flex items-center gap-1">
{workflow && onEdit ? (
<Button
onClick={() => onEdit(workflow)}
size="sm"
variant="outline"
>
<Pencil className="mr-1 h-4 w-4" />
Edit
</Button>
) : null}
<Button
onClick={() => onEdit(workflow)}
disabled={triggerMutation.isPending || workflowQuery.isLoading}
onClick={() => void handleTrigger()}
size="sm"
variant="outline"
>
<Pencil className="mr-1 h-4 w-4" />
Edit
<Play className="mr-1 h-4 w-4" />
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
</Button>
) : null}
<Button
disabled={triggerMutation.isPending || workflowQuery.isLoading}
onClick={() => void handleTrigger()}
size="sm"
variant="outline"
>
<Play className="mr-1 h-4 w-4" />
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
</Button>
<Button
aria-label="Close detail panel"
onClick={onClose}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
{onClose ? (
<Button
aria-label="Close detail panel"
onClick={onClose}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
</div>
) : null}
{triggerMutation.isError ? (
<div
@@ -144,20 +158,28 @@ export function WorkflowDetailPanel({
data-scroll-restoration-id={`workflow-detail:${workflowId}`}
>
{workflow ? (
<div className="space-y-4 p-4">
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Definition
</h4>
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
{JSON.stringify(workflow.definition, null, 2)}
</pre>
</div>
<div
className={
showHeader ? "space-y-4 p-4" : "space-y-4 px-5 pb-5 pt-2"
}
>
{showDefinition ? (
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Definition
</h4>
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
{JSON.stringify(workflow.definition, null, 2)}
</pre>
</div>
) : null}
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Run History
</h4>
{showHeader ? (
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Run History
</h4>
) : null}
{runsQuery.isError ? (
<div
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
@@ -297,7 +319,11 @@ export function WorkflowDetailPanel({
<p className="text-sm text-red-400">Failed to load workflow</p>
</div>
) : (
<div className="space-y-4 p-4">
<div
className={
showHeader ? "space-y-4 p-4" : "space-y-4 px-5 pb-5 pt-2"
}
>
<div>
<Skeleton className="mb-2 h-4 w-28" />
<Skeleton className="h-40 w-full rounded-xl" />

View File

@@ -1,32 +1,73 @@
import * as React from "react";
import { Check, Code, Pencil, X } from "lucide-react";
import { useBlocker } from "@tanstack/react-router";
import { stringify as yamlStringify } from "yaml";
import {
useCreateWorkflowMutation,
useUpdateWorkflowMutation,
} from "@/features/workflows/hooks";
import { generateBackupPassphrase } from "@/shared/api/tauriIdentity";
import type { Channel, Workflow } from "@/shared/api/types";
import { getRelayHttpUrl } from "@/shared/api/tauri";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent } from "@/shared/ui/popover";
import { ChannelCombobox } from "./ChannelCombobox";
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
import { WorkflowActionsMenu } from "./WorkflowActionsMenu";
import { WorkflowDetailPanel } from "./WorkflowDetailPanel";
import {
WorkflowFormBuilder,
type WorkflowEditorMode,
type WorkflowFormBuilderHandle,
} from "./WorkflowFormBuilder";
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
import { FieldLabel } from "./workflowFormPrimitives";
import { getWorkflowEnabled } from "./workflowDefinition";
import type { WorkflowEditorPane } from "./workflowEditorPane";
import {
DEFAULT_FORM_STATE,
formStateToYaml,
yamlToFormState,
} from "./workflowFormTypes";
import {
readWorkflowHeaderState,
yamlWithWorkflowEnabled,
yamlWithWorkflowName,
} from "./workflowYamlDocument";
type DialogMode = "create" | "edit" | "duplicate";
type WorkflowDialogProps = {
channels: Channel[];
initialChannelId?: string;
mode: DialogMode;
onDeleteWorkflow: (workflow: Workflow) => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
onOpenChange: (open: boolean) => void;
onTriggerWorkflow: (workflowId: string) => void;
open: boolean;
pane: WorkflowEditorPane;
workflow?: Workflow | null;
};
@@ -42,93 +83,323 @@ function getInitialYaml(
return yamlStringify(def);
}
function getInitialEditorMode(yaml: string): WorkflowEditorMode {
if (!yaml) return "form";
return yamlToFormState(yaml).ok ? "form" : "yaml";
}
const TITLES: Record<DialogMode, string> = {
create: "Create Workflow",
edit: "Edit Workflow",
duplicate: "Duplicate Workflow",
create: "Create workflow",
edit: "Edit workflow",
duplicate: "Duplicate workflow",
};
const SUBMIT_LABELS: Record<DialogMode, string> = {
create: "Create",
edit: "Save",
duplicate: "Create Copy",
create: "Create workflow",
edit: "Save changes",
duplicate: "Create copy",
};
const PENDING_LABELS: Record<DialogMode, string> = {
create: "Creating...",
edit: "Saving...",
duplicate: "Creating...",
create: "Creating…",
edit: "Saving…",
duplicate: "Creating…",
};
function WorkflowNameEditor({
disabled,
generating,
name,
onCommit,
onEditingChange,
}: {
disabled: boolean;
generating: boolean;
name: string;
onCommit: (name: string) => boolean;
onEditingChange: (editing: boolean) => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(name);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (!editing) setDraft(name);
}, [editing, name]);
React.useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const changeEditing = React.useCallback(
(nextEditing: boolean) => {
setEditing(nextEditing);
onEditingChange(nextEditing);
},
[onEditingChange],
);
const commit = React.useCallback(() => {
const nextName = inputRef.current?.value.trim() ?? draft.trim();
if (!nextName || !onCommit(nextName)) return;
changeEditing(false);
}, [changeEditing, draft, onCommit]);
if (editing) {
return (
<div className="flex h-6 items-center gap-1.5">
<Input
aria-label="Workflow name"
autoCapitalize="off"
autoCorrect="off"
className="h-6 w-72 px-2 font-mono text-sm"
disabled={disabled}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(name);
changeEditing(false);
}
}}
ref={inputRef}
defaultValue={name}
/>
<Button
aria-label="Save workflow name"
className="text-muted-foreground"
disabled={disabled || !draft.trim()}
onClick={commit}
onPointerDown={(event) => {
// Commit before pointer focus changes can re-render the portalled
// header controls and restore the generated name.
event.preventDefault();
commit();
}}
size="icon-xs"
title="Save workflow name"
type="button"
variant="ghost"
>
<Check />
</Button>
</div>
);
}
return (
<div className="inline-flex h-6 max-w-full min-w-0 items-center gap-1.5 font-mono text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{generating ? "Generating name…" : name || "Untitled workflow"}
</span>
<Button
aria-label="Edit workflow name"
className="text-muted-foreground [&_svg]:size-3"
disabled={disabled || generating}
onClick={() => changeEditing(true)}
size="icon-xs"
title="Edit workflow name"
type="button"
variant="ghost"
>
<Pencil />
</Button>
</div>
);
}
export function WorkflowDialog({
channels,
initialChannelId,
mode,
onDeleteWorkflow,
onDuplicateWorkflow,
onEditWorkflow,
onEditorPaneChange,
onOpenChange,
onTriggerWorkflow,
open,
pane,
workflow,
}: WorkflowDialogProps) {
const formBuilderRef = React.useRef<WorkflowFormBuilderHandle>(null);
const workflowSnapshotRef = React.useRef(workflow);
const workflowSnapshot = workflowSnapshotRef.current;
const channelId =
mode === "edit" && workflow?.channelId
? workflow.channelId
: (channels[0]?.id ?? "");
mode === "edit" && workflowSnapshot?.channelId
? workflowSnapshot.channelId
: mode === "create" &&
initialChannelId &&
channels.some((channel) => channel.id === initialChannelId)
? initialChannelId
: "";
const [selectedChannelId, setSelectedChannelId] = React.useState(channelId);
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
getInitialYaml(mode, workflow),
getInitialYaml(mode, workflowSnapshot),
);
const [editorMode, setEditorMode] = React.useState<WorkflowEditorMode>(() =>
getInitialEditorMode(getInitialYaml(mode, workflowSnapshot)),
);
const [editorParseError, setEditorParseError] = React.useState<string | null>(
null,
);
const [workflowNameEditing, setWorkflowNameEditing] = React.useState(false);
const [historyOpen, setHistoryOpen] = React.useState(false);
const [channelAutoOpenPending, setChannelAutoOpenPending] = React.useState(
mode === "create" && !channelId,
);
const [nameLeadingElement, setNameLeadingElement] =
React.useState<HTMLDivElement | null>(null);
const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{
relayHttpUrl: string;
relayHttpUrl: string | null;
relayUrlError: string | null;
webhookSecret: string;
workflowId: string;
} | null>(null);
const [discardConfirmationOpen, setDiscardConfirmationOpen] =
React.useState(false);
const [secretConfirmationOpen, setSecretConfirmationOpen] =
React.useState(false);
const [generatingName, setGeneratingName] = React.useState(false);
const initialValuesRef = React.useRef({
channelId,
yaml: getInitialYaml(mode, workflowSnapshot),
});
const yamlDefinitionRef = React.useRef(yamlDefinition);
const allowNavigationRef = React.useRef(false);
const proceedingNavigationRef = React.useRef(false);
const pendingEditorTransitionRef = React.useRef<(() => void) | null>(null);
const createMutation = useCreateWorkflowMutation(selectedChannelId);
const updateMutation = useUpdateWorkflowMutation(
workflow?.id ?? "",
workflow?.revision ?? "",
workflowSnapshot?.id ?? "",
workflowSnapshot?.revision ?? "",
);
const mutation = mode === "edit" ? updateMutation : createMutation;
const selectedChannel =
channels.find((c) => c.id === selectedChannelId) ?? null;
const parsedDefinition = yamlDefinition.trim()
? yamlToFormState(yamlDefinition)
: null;
const isAddingFirstStep =
mode === "create" &&
editorMode === "form" &&
(parsedDefinition === null ||
(parsedDefinition.ok && parsedDefinition.state.steps.length === 0));
const defaultChannelId = channels[0]?.id ?? "";
const workflowChannelId = workflow?.channelId ?? null;
const resetCreate = createMutation.reset;
const resetUpdate = updateMutation.reset;
// Re-initialize when dialog opens or workflow/mode changes
React.useEffect(() => {
if (open) {
const newChannelId =
mode === "edit" && workflowChannelId
? workflowChannelId
: defaultChannelId;
setSelectedChannelId(newChannelId);
setYamlDefinition(getInitialYaml(mode, workflow));
setSavedWebhookInfo(null);
resetCreate();
resetUpdate();
let active = true;
setSavedWebhookInfo(null);
setDiscardConfirmationOpen(false);
resetCreate();
resetUpdate();
if (mode === "create" && !workflowSnapshot) {
setGeneratingName(true);
void generateBackupPassphrase({ words: 3, separator: "-" })
.then((name) => {
if (!active || yamlDefinitionRef.current.trim()) return;
const generatedYaml = formStateToYaml({
...DEFAULT_FORM_STATE,
name,
});
yamlDefinitionRef.current = generatedYaml;
initialValuesRef.current = {
...initialValuesRef.current,
yaml: generatedYaml,
};
setYamlDefinition(generatedYaml);
formBuilderRef.current?.synchronizeYaml(generatedYaml);
})
.catch(() => {
// Leave the editable "Untitled workflow" fallback in place.
})
.finally(() => {
if (active) setGeneratingName(false);
});
} else {
setGeneratingName(false);
}
}, [
open,
mode,
workflow,
workflowChannelId,
defaultChannelId,
resetCreate,
resetUpdate,
]);
return () => {
active = false;
};
}, [mode, resetCreate, resetUpdate, workflowSnapshot]);
const closeDialog = React.useCallback(() => {
resetCreate();
resetUpdate();
setDiscardConfirmationOpen(false);
onOpenChange(false);
}, [onOpenChange, resetCreate, resetUpdate]);
const isDirty =
yamlDefinition !== initialValuesRef.current.yaml ||
selectedChannelId !== initialValuesRef.current.channelId;
const navigationBlocker = useBlocker({
enableBeforeUnload: isDirty || savedWebhookInfo !== null,
shouldBlockFn: ({ current, next }) => {
const currentSearch = current.search as {
pane?: unknown;
view?: unknown;
};
const nextSearch = next.search as { pane?: unknown; view?: unknown };
const isPaneOnlyNavigation =
current.pathname === next.pathname &&
currentSearch.view === nextSearch.view &&
currentSearch.pane !== nextSearch.pane;
return (
(isDirty || savedWebhookInfo !== null) &&
!allowNavigationRef.current &&
!isPaneOnlyNavigation
);
},
withResolver: true,
});
React.useEffect(() => {
if (navigationBlocker.status === "blocked") {
if (savedWebhookInfo) {
setSecretConfirmationOpen(true);
} else {
setDiscardConfirmationOpen(true);
}
}
}, [navigationBlocker.status, savedWebhookInfo]);
const requestEditorTransition = React.useCallback(
(transition: () => void) => {
if (isDirty) {
pendingEditorTransitionRef.current = transition;
setDiscardConfirmationOpen(true);
return;
}
transition();
},
[isDirty],
);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
resetCreate();
resetUpdate();
if (nextOpen) {
onOpenChange(true);
} else if (savedWebhookInfo) {
setSecretConfirmationOpen(true);
} else if (isDirty) {
setDiscardConfirmationOpen(true);
} else {
closeDialog();
}
onOpenChange(nextOpen);
},
[onOpenChange, resetCreate, resetUpdate],
[closeDialog, isDirty, onOpenChange, savedWebhookInfo],
);
async function handleSubmit() {
@@ -136,118 +407,472 @@ export function WorkflowDialog({
try {
const saved = await mutation.mutateAsync(yamlDefinition);
handleOpenChange(false);
initialValuesRef.current = {
channelId: selectedChannelId,
yaml: yamlDefinition,
};
if (saved.webhookSecret) {
const relayHttpUrl = await getRelayHttpUrl();
setSavedWebhookInfo({
relayHttpUrl,
allowNavigationRef.current = false;
const webhookInfo = {
relayHttpUrl: null,
relayUrlError: null,
webhookSecret: saved.webhookSecret,
workflowId: saved.workflow.id,
});
};
setSavedWebhookInfo(webhookInfo);
try {
const relayHttpUrl = await getRelayHttpUrl();
setSavedWebhookInfo({ ...webhookInfo, relayHttpUrl });
} catch (error) {
setSavedWebhookInfo({
...webhookInfo,
relayUrlError:
error instanceof Error
? error.message
: "Could not load the webhook URL",
});
}
} else {
allowNavigationRef.current = true;
closeDialog();
}
} catch {
// React Query stores the error; keep the dialog open.
// React Query stores the error; keep the dialog open and dirty.
}
}
const showChannelSelector = mode !== "edit" && channels.length > 1;
const showChannelInfo = mode !== "edit" && channels.length === 1;
const handleEditorModeChange = React.useCallback(
(nextMode: string) => {
if (nextMode === editorMode) return;
if (nextMode === "yaml") {
setEditorParseError(null);
setEditorMode("yaml");
return;
}
if (!yamlDefinition.trim()) {
setEditorParseError(null);
setEditorMode("form");
return;
}
const result = yamlToFormState(yamlDefinition);
if (result.ok) {
setEditorParseError(null);
setEditorMode("form");
} else {
setEditorParseError(result.error);
}
},
[editorMode, yamlDefinition],
);
// Header state reads the YAML document directly rather than the fully
// validated form state: a step that is still being filled in (a new
// send_message with no text yet) fails form validation, and gating the name
// on that made the title blank out as soon as a step pane opened.
const {
canEdit: canEditWorkflowName,
enabled: workflowEnabled,
name: workflowName,
} = readWorkflowHeaderState(yamlDefinition, {
enabled: workflowSnapshot
? getWorkflowEnabled(workflowSnapshot.definition)
: true,
name: workflowSnapshot?.name,
});
const handleWorkflowNameCommit = React.useCallback(
(name: string) => {
const nextYaml = yamlWithWorkflowName(yamlDefinitionRef.current, name);
if (nextYaml === null) return false;
mutation.reset();
yamlDefinitionRef.current = nextYaml;
setYamlDefinition(nextYaml);
return true;
},
[mutation.reset],
);
const handleToggleWorkflowEnabled = React.useCallback(() => {
const nextYaml = yamlWithWorkflowEnabled(
yamlDefinitionRef.current,
!workflowEnabled,
);
if (nextYaml === null) return;
mutation.reset();
yamlDefinitionRef.current = nextYaml;
setYamlDefinition(nextYaml);
}, [mutation.reset, workflowEnabled]);
const showChannelSelector = mode !== "edit";
return (
<>
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{TITLES[mode]}</DialogTitle>
<DialogDescription>
{mode === "edit"
? "Modify the workflow definition."
: channels.length === 1
? "Create a workflow scoped to this channel."
: "Define a workflow and assign it to a channel."}
</DialogDescription>
<Dialog
onOpenChange={handleOpenChange}
open={open && savedWebhookInfo === null}
>
<DialogContent
className="flex h-[88vh] max-h-[88vh] w-[calc(100vw-2rem)] max-w-6xl flex-col gap-0 overflow-hidden p-0"
onEscapeKeyDown={(event) => {
if (formBuilderRef.current?.closeInspector()) {
event.preventDefault();
event.stopPropagation();
}
}}
showCloseButton={false}
>
<DialogHeader className="flex flex-shrink-0 flex-row items-center justify-between gap-6 space-y-0 px-6 pt-3 pb-2 text-left">
<div className="space-y-0">
<DialogTitle className="text-lg leading-tight">
{TITLES[mode]}
</DialogTitle>
<DialogDescription className="sr-only">
{mode === "edit"
? "Update when this workflow runs and what it does."
: mode === "duplicate"
? "Copy this workflow and adjust its details."
: "Automate actions when something happens in a channel."}
</DialogDescription>
<div className="flex items-center gap-2">
<WorkflowNameEditor
disabled={mutation.isPending || !canEditWorkflowName}
generating={generatingName}
name={workflowName}
onCommit={handleWorkflowNameCommit}
onEditingChange={setWorkflowNameEditing}
/>
<div
className={
mode !== "edit" &&
editorMode === "form" &&
!workflowNameEditing
? "flex items-center"
: "hidden"
}
ref={setNameLeadingElement}
/>
</div>
</div>
<div className="flex items-center gap-2">
{mode === "edit" && workflowSnapshot ? (
<>
<Popover onOpenChange={setHistoryOpen} open={historyOpen}>
{/* TODO(workflow-run-history-capability): Restore this
icon-only entry point after Desktop gates it on the active
relay's advertised NIP-11 capabilities.
<PopoverTrigger asChild>
<Button
aria-label="Run history"
className="h-8 w-8"
size="icon"
type="button"
variant={historyOpen ? "secondary" : "outline"}
>
<History className="h-4 w-4" />
</Button>
</PopoverTrigger>
*/}
<PopoverContent
align="end"
aria-label="Run history"
className="flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-96 max-w-[calc(100vw-2rem)] flex-col overflow-hidden p-0"
data-testid="workflow-history-dropdown"
sideOffset={8}
>
<div className="flex-shrink-0 border-b px-5 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Workflow
</p>
<h3 className="text-base font-semibold">Run history</h3>
</div>
<div className="min-h-0 flex-1">
<WorkflowDetailPanel
showDefinition={false}
showHeader={false}
workflowId={workflowSnapshot.id}
/>
</div>
</PopoverContent>
</Popover>
<WorkflowActionsMenu
isEnabled={workflowEnabled}
isTogglingEnabled={
mutation.isPending || !canEditWorkflowName
}
onDelete={() => onDeleteWorkflow(workflowSnapshot)}
onDuplicate={() =>
requestEditorTransition(() =>
onDuplicateWorkflow(workflowSnapshot.id),
)
}
onEdit={() =>
requestEditorTransition(() =>
onEditWorkflow(workflowSnapshot.id),
)
}
onToggleEnabled={handleToggleWorkflowEnabled}
onTrigger={() => onTriggerWorkflow(workflowSnapshot.id)}
/>
</>
) : null}
<DialogClose asChild>
<Button
aria-label="Close"
className="h-8 w-8 text-muted-foreground"
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
{showChannelSelector ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
<ChannelCombobox
channels={channels}
disabled={mutation.isPending}
id="wf-channel-select"
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
}}
value={selectedChannelId}
/>
<p className="text-xs text-muted-foreground">
{selectedChannel
? `New workflows will belong to ${selectedChannel.name}.`
: "Join or create a channel before adding a workflow."}
</p>
</div>
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
<p className="text-sm text-muted-foreground">
{mode === "edit"
? "Editing workflow in"
: "This workflow will be created in"}{" "}
<span className="font-medium text-foreground">
{selectedChannel.name}
</span>
.
</p>
) : null}
<div className="min-h-0 flex-1">
<WorkflowFormBuilder
channels={channels}
disabled={mutation.isPending}
mode={editorMode}
nameLeadingContainer={mode === "edit" ? null : nameLeadingElement}
onChange={(yaml) => {
mutation.reset();
yamlDefinitionRef.current = yaml;
setYamlDefinition(yaml);
}}
onSelectedNodeChange={onEditorPaneChange}
parseError={editorParseError}
ref={formBuilderRef}
scopeField={
showChannelSelector ? (
<div className="space-y-1">
<ChannelCombobox
channels={channels}
defaultOpen={
channelAutoOpenPending &&
editorMode === "form" &&
!selectedChannelId
}
disabled={mutation.isPending}
id="wf-channel-select"
onAutoOpen={() => setChannelAutoOpenPending(false)}
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
if (value) onEditorPaneChange({ type: "trigger" });
}}
required
variant={editorMode === "yaml" ? "field" : "header"}
value={selectedChannelId}
/>
{channels.length === 0 ? (
<p className="text-center text-xs text-muted-foreground">
Join or create a channel before adding a workflow.
</p>
) : null}
</div>
) : mode === "edit" && selectedChannel ? (
<ChannelCombobox
channels={channels}
id="wf-channel-select"
onChange={setSelectedChannelId}
readOnly
variant={editorMode === "yaml" ? "field" : "header"}
value={selectedChannel.id}
/>
) : null
}
selectedNode={
mode === "create" && !selectedChannelId ? null : pane
}
workflowChannelId={selectedChannelId || null}
yaml={yamlDefinition}
/>
{mutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mutation.error.message}
</p>
) : null}
</div>
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
{mutation.error instanceof Error ? (
<p
aria-live="polite"
className="mx-6 mb-3 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
Cancel
</Button>
<Button
disabled={
!selectedChannelId ||
!yamlDefinition.trim() ||
mutation.isPending
}
onClick={handleSubmit}
type="button"
>
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
</Button>
{mutation.error.message}
</p>
) : null}
<div className="flex flex-shrink-0 items-center justify-between gap-4 px-6 pt-2 pb-4">
<Tabs onValueChange={handleEditorModeChange} value={editorMode}>
<TabsList aria-label="Workflow editor mode" className="h-8 p-0.5">
<TabsTrigger
className="h-7 px-3 text-xs"
disabled={mutation.isPending}
value="form"
>
Form
</TabsTrigger>
<TabsTrigger
className="h-7 gap-1.5 px-3 text-xs"
disabled={mutation.isPending}
value="yaml"
>
<Code className="h-3.5 w-3.5" />
YAML
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-2">
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
{isAddingFirstStep ? (
<Button
aria-label="Add first step"
data-testid="workflow-dialog-primary-action"
disabled={!selectedChannelId || mutation.isPending}
onClick={() => formBuilderRef.current?.addFirstStep()}
type="button"
>
Add step
</Button>
) : (
<Button
data-testid="workflow-dialog-primary-action"
disabled={
!selectedChannelId ||
!yamlDefinition.trim() ||
mutation.isPending
}
onClick={handleSubmit}
type="button"
>
{mutation.isPending
? PENDING_LABELS[mode]
: SUBMIT_LABELS[mode]}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog
onOpenChange={(nextOpen) => {
setDiscardConfirmationOpen(nextOpen);
if (!nextOpen) {
pendingEditorTransitionRef.current = null;
}
if (
!nextOpen &&
navigationBlocker.status === "blocked" &&
!proceedingNavigationRef.current
) {
navigationBlocker.reset();
}
}}
open={discardConfirmationOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
Your unsaved workflow changes will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Keep editing
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
const pendingEditorTransition =
pendingEditorTransitionRef.current;
pendingEditorTransitionRef.current = null;
setDiscardConfirmationOpen(false);
if (pendingEditorTransition) {
allowNavigationRef.current = true;
pendingEditorTransition();
return;
}
if (navigationBlocker.status === "blocked") {
proceedingNavigationRef.current = true;
navigationBlocker.proceed();
return;
}
allowNavigationRef.current = true;
closeDialog();
}}
type="button"
variant="destructive"
>
Discard changes
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
onOpenChange={(nextOpen) => {
setSecretConfirmationOpen(nextOpen);
if (
!nextOpen &&
navigationBlocker.status === "blocked" &&
!proceedingNavigationRef.current
) {
navigationBlocker.reset();
}
}}
open={secretConfirmationOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Continue without this secret?</AlertDialogTitle>
<AlertDialogDescription>
This private webhook secret cannot be recovered. Copy and store it
before continuing, or explicitly leave it behind.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Go back
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setSecretConfirmationOpen(false);
allowNavigationRef.current = true;
if (navigationBlocker.status === "blocked") {
proceedingNavigationRef.current = true;
navigationBlocker.proceed();
return;
}
closeDialog();
}}
type="button"
variant="destructive"
>
Continue
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{savedWebhookInfo ? (
<WorkflowWebhookSecretDialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setSavedWebhookInfo(null);
}
}}
onContinue={() => setSecretConfirmationOpen(true)}
open
relayHttpUrl={savedWebhookInfo.relayHttpUrl}
relayUrlError={savedWebhookInfo.relayUrlError}
webhookSecret={savedWebhookInfo.webhookSecret}
workflowId={savedWebhookInfo.workflowId}
/>

View File

@@ -0,0 +1,95 @@
import { Input } from "@/shared/ui/input";
import { FieldLabel } from "./workflowFormPrimitives";
import {
DEFAULT_DURATION_SECONDS,
DURATION_SLIDER_STOPS,
durationSliderIndex,
formatDurationSeconds,
parseDurationSeconds,
} from "./workflowDuration";
export function WorkflowDurationField({
disabled,
fallbackSeconds = DEFAULT_DURATION_SECONDS,
hideLabel = false,
id,
label = "Duration",
onChange,
placeholder = "1s",
value,
}: {
disabled?: boolean;
fallbackSeconds?: number;
hideLabel?: boolean;
id: string;
label?: string;
onChange: (value: string) => void;
placeholder?: string;
value: string;
}) {
const parsedSeconds = parseDurationSeconds(value);
const sliderIndex = durationSliderIndex(
Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds ?? fallbackSeconds),
);
const progress = (sliderIndex / (DURATION_SLIDER_STOPS.length - 1)) * 100;
const sliderSeconds = DURATION_SLIDER_STOPS[sliderIndex];
return (
<div className="space-y-1.5">
{hideLabel ? (
<label className="sr-only" htmlFor={id}>
{label}
</label>
) : (
<FieldLabel htmlFor={id}>{label}</FieldLabel>
)}
<div className="flex items-center gap-4">
<div className="relative flex h-9 min-w-0 flex-1 items-center">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${progress}%` }}
/>
</div>
<input
aria-label={`${label} slider`}
aria-valuetext={formatDurationSeconds(sliderSeconds)}
className="absolute inset-x-0 h-9 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:mt-[-0.3125rem] [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm focus-visible:outline-hidden focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-ring focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring focus-visible:[&::-webkit-slider-thumb]:ring-offset-2"
disabled={disabled}
max={DURATION_SLIDER_STOPS.length - 1}
min={0}
onChange={(event) => {
const seconds = DURATION_SLIDER_STOPS[Number(event.target.value)];
onChange(formatDurationSeconds(seconds));
}}
type="range"
value={sliderIndex}
/>
</div>
<Input
autoCapitalize="off"
autoCorrect="off"
className="w-28 shrink-0 text-center tabular-nums"
disabled={disabled}
id={id}
onBlur={() => {
if (parsedSeconds !== null) {
onChange(
formatDurationSeconds(
Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds),
),
);
}
}}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
spellCheck={false}
value={value}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useWorkflowQuery } from "@/features/workflows/hooks";
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import { WorkflowUnavailableDialog } from "@/features/workflows/ui/WorkflowUnavailableDialog";
import type { Channel, Workflow } from "@/shared/api/types";
import type { WorkflowEditorPane } from "./workflowEditorPane";
/** Create target for the shared workflow editor. */
export type WorkflowEditorCreateTarget = {
initialChannelId?: string;
mode: "create";
pane: WorkflowEditorPane;
};
/** Existing-workflow target for the shared workflow editor. */
export type WorkflowEditorWorkflowTarget = {
mode: "detail" | "duplicate" | "edit";
pane: WorkflowEditorPane;
workflowId: string;
};
/**
* What the workflow editor is currently pointed at, independent of how it was
* opened. The Workflows route derives this from the URL; the channel-anchored
* overlay derives it from local state so the channel stays behind the modal.
*/
export type WorkflowEditorTarget =
| WorkflowEditorCreateTarget
| WorkflowEditorWorkflowTarget;
type WorkflowEditorHostProps = {
channels: Channel[];
editor: WorkflowEditorTarget | null;
onClose: () => void;
onDeleteWorkflow: (workflow: Workflow) => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
onTriggerWorkflow: (workflowId: string) => void;
/**
* Workflow the opening surface already holds for this target. Supplying it
* skips the loading dialog the detail query would otherwise show first.
*/
workflowHint?: Workflow;
};
/**
* Renders the shared workflow editor (or its non-disclosing loading /
* unavailable stand-in) for a target. Every surface that can open the editor
* mounts this so none of them fork the editor's lifecycle.
*/
export function WorkflowEditorHost({
channels,
editor,
onClose,
onDeleteWorkflow,
onDuplicateWorkflow,
onEditWorkflow,
onEditorPaneChange,
onTriggerWorkflow,
workflowHint,
}: WorkflowEditorHostProps) {
const editorWorkflowId =
editor && editor.mode !== "create" ? editor.workflowId : null;
const editorWorkflowQuery = useWorkflowQuery(editorWorkflowId);
const editorWorkflow =
workflowHint?.id === editorWorkflowId
? workflowHint
: editorWorkflowQuery.data;
if (!editor) return null;
if (editor.mode !== "create" && editorWorkflow === undefined) {
return (
<WorkflowUnavailableDialog
loading={editorWorkflowQuery.isLoading}
onOpenChange={(open) => {
if (!open) onClose();
}}
onRetry={() => void editorWorkflowQuery.refetch()}
open
/>
);
}
return (
<WorkflowDialog
channels={channels}
initialChannelId={
editor.mode === "create" ? editor.initialChannelId : undefined
}
key={
editor.mode === "create"
? editor.mode
: `${editor.mode}:${editor.workflowId}`
}
mode={editor.mode === "detail" ? "edit" : editor.mode}
onDeleteWorkflow={onDeleteWorkflow}
onDuplicateWorkflow={onDuplicateWorkflow}
onEditWorkflow={onEditWorkflow}
onEditorPaneChange={onEditorPaneChange}
onOpenChange={(open) => {
if (!open) onClose();
}}
onTriggerWorkflow={onTriggerWorkflow}
open
pane={editor.pane}
workflow={editorWorkflow}
/>
);
}

View File

@@ -0,0 +1,96 @@
import { SmilePlus, X } from "lucide-react";
import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
/**
* Emoji chooser for workflow editor fields that hold a single reaction.
*
* Reactions are stored as the reaction event's content: a native glyph (`👍`)
* or a custom-emoji `:shortcode:`. The shared `EmojiPicker` emits exactly that
* string, so the selection is stored verbatim — no translation layer, and the
* value lines up with what the executor compares `trigger_emoji` against.
*
* A free-text field cannot express that contract (a typed `thumbsup` never
* matches a `👍` reaction), which is why picking is the only input path here.
* Clearing is separate from picking: the picker has no "no emoji" cell, so an
* optional field gets an explicit clear button that emits `undefined`.
*/
type WorkflowEmojiFieldProps = {
ariaLabel: string;
/** Renders a clear button when set and a value is present. Omit for required fields. */
clearAriaLabel?: string;
disabled?: boolean;
id: string;
onChange: (emoji: string | undefined) => void;
value?: string;
};
export function WorkflowEmojiField({
ariaLabel,
clearAriaLabel,
disabled,
id,
onChange,
value,
}: WorkflowEmojiFieldProps) {
const [pickerOpen, setPickerOpen] = React.useState(false);
return (
<div className="flex gap-2">
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<PopoverTrigger asChild>
<Button
aria-label={ariaLabel}
className="flex-1 justify-start px-3 font-normal"
disabled={disabled}
id={id}
type="button"
variant="outline"
>
{value ? (
<>
<StatusEmoji className="h-5 w-5 text-base" value={value} />
<span>{emojiDisplayName(value)}</span>
</>
) : (
<>
<SmilePlus className="text-muted-foreground" />
<span className="text-muted-foreground">Choose a reaction</span>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
sideOffset={4}
>
<EmojiPicker
autoFocus
onSelect={(emoji) => {
onChange(emoji);
setPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
{value && clearAriaLabel ? (
<Button
aria-label={clearAriaLabel}
disabled={disabled}
onClick={() => onChange(undefined)}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
) : null}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,290 @@
import { ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
MESSAGE_TEXT_CONDITION_LABELS,
MESSAGE_TEXT_CONDITION_OPERATORS,
buildMessageTextCondition,
messageTextConditionNeedsValue,
parseMessageTextCondition,
} from "./workflowMessageTextCondition";
import type { MessageTextCondition } from "./workflowMessageTextCondition";
const DEFAULT_CONDITION: MessageTextCondition = {
operator: "contains",
value: "",
};
type EditorMode = "basic" | "advanced";
function conditionSummary(condition: MessageTextCondition): string {
const label = MESSAGE_TEXT_CONDITION_LABELS[condition.operator].toLowerCase();
if (!messageTextConditionNeedsValue(condition.operator)) return label;
const value = condition.value.trim();
return value ? `${label} “${value}”` : "Any";
}
export function WorkflowMessageTextCondition({
allowAdvanced = true,
disabled,
onChange,
value,
}: {
allowAdvanced?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
value: string;
}) {
const initialParsed = React.useRef(parseMessageTextCondition(value));
const [mode, setMode] = React.useState<EditorMode>(() =>
value && !initialParsed.current ? "advanced" : "basic",
);
const [expanded, setExpanded] = React.useState(
() => !value || initialParsed.current !== null,
);
const [replaceAdvancedOpen, setReplaceAdvancedOpen] = React.useState(false);
const [condition, setCondition] = React.useState<MessageTextCondition>(
initialParsed.current ?? DEFAULT_CONDITION,
);
const [advancedDraft, setAdvancedDraft] = React.useState(value);
const localValueRef = React.useRef(value);
const parsed = value ? parseMessageTextCondition(value) : null;
const hasUnsupportedExpression = Boolean(value) && parsed === null;
React.useEffect(() => {
if (value === localValueRef.current) return;
localValueRef.current = value;
setAdvancedDraft(value);
const nextParsed = parseMessageTextCondition(value);
if (nextParsed) {
setCondition(nextParsed);
setMode("basic");
setExpanded(true);
} else if (!value) {
setCondition(DEFAULT_CONDITION);
setMode("basic");
setExpanded(true);
} else {
setMode("advanced");
setExpanded(false);
}
}, [value]);
const emit = (expression: string) => {
localValueRef.current = expression;
onChange(expression);
};
const updateCondition = (next: MessageTextCondition) => {
setCondition(next);
emit(buildMessageTextCondition(next) ?? "");
};
const openBasicEditor = () => {
if (hasUnsupportedExpression) {
setReplaceAdvancedOpen(true);
return;
}
setExpanded((current) => !current);
};
return (
<>
<Tabs
className="space-y-3"
onValueChange={(nextMode) => {
if (!allowAdvanced) return;
const editorMode = nextMode as EditorMode;
setMode(editorMode);
setExpanded(false);
if (editorMode === "advanced") setAdvancedDraft(value);
}}
value={allowAdvanced ? mode : "basic"}
>
{allowAdvanced ? (
<TabsList
aria-label="Condition editor mode"
className="grid h-9 w-full grid-cols-2 p-0.5"
>
<TabsTrigger className="h-8" disabled={disabled} value="basic">
Basic
</TabsTrigger>
<TabsTrigger className="h-8" disabled={disabled} value="advanced">
Advanced
</TabsTrigger>
</TabsList>
) : null}
{!allowAdvanced || mode === "basic" ? (
<div>
{hasUnsupportedExpression ? (
<p className="border-b border-border/50 pb-3 text-xs text-muted-foreground">
An advanced expression is active. Choosing a basic filter will
replace it.
</p>
) : null}
<div className="divide-y divide-border/50">
<div className={cn(expanded && "pb-4 last:pb-0")}>
<button
aria-expanded={expanded}
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={openBasicEditor}
type="button"
>
<span className="min-w-0 flex-1 truncate text-base font-medium">
Message text
</span>
<span className="max-w-40 truncate text-sm text-muted-foreground">
{hasUnsupportedExpression
? "Any"
: conditionSummary(condition)}
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
</button>
{expanded && !hasUnsupportedExpression ? (
<div className="animate-in space-y-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
<fieldset>
<legend className="sr-only">Match</legend>
<div className="grid grid-cols-2 gap-2.5">
{MESSAGE_TEXT_CONDITION_OPERATORS.map((operator) => {
const id = `wf-trigger-filter-trigger-text-operator-${operator}`;
return (
<div className="relative" key={operator}>
<input
checked={condition.operator === operator}
className="peer sr-only"
disabled={disabled}
id={id}
name="wf-trigger-filter-trigger-text-operator"
onChange={() =>
updateCondition({ ...condition, operator })
}
type="radio"
value={operator}
/>
<label
className={cn(
"flex min-h-12 cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color]",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
condition.operator === operator
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
)}
htmlFor={id}
>
{MESSAGE_TEXT_CONDITION_LABELS[
operator
].toLowerCase()}
</label>
</div>
);
})}
</div>
</fieldset>
{messageTextConditionNeedsValue(condition.operator) ? (
<Input
aria-label="Text to match"
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id="wf-trigger-filter-trigger-text-value"
onChange={(event) =>
updateCondition({
...condition,
value: event.target.value,
})
}
placeholder="e.g. deploy"
value={condition.value}
/>
) : null}
</div>
) : null}
</div>
</div>
</div>
) : (
<div className="animate-in space-y-2 fade-in duration-150 motion-reduce:animate-none">
<Input
aria-label="Advanced expression"
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id="wf-trigger-filter-advanced-expression"
onChange={(event) => {
const expression = event.target.value;
setAdvancedDraft(expression);
emit(expression.trim());
}}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={advancedDraft}
/>
<p className="text-xs text-muted-foreground">
Use an evalexpr expression with <code>trigger_text</code>.
</p>
</div>
)}
</Tabs>
<AlertDialog
onOpenChange={setReplaceAdvancedOpen}
open={replaceAdvancedOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Replace advanced expression?</AlertDialogTitle>
<AlertDialogDescription>
Choosing a basic filter will replace the entire advanced
expression. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Keep advanced expression
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setCondition(DEFAULT_CONDITION);
setAdvancedDraft("");
setExpanded(true);
emit("");
}}
type="button"
variant="destructive"
>
Replace expression
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,258 @@
import { AlertTriangle } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { CronExpressionInput } from "./CronExpressionInput";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import type { TriggerConfig } from "./workflowFormTypes";
import {
SCHEDULE_FREQUENCIES,
SCHEDULE_FREQUENCY_LABELS,
scheduleFormFromTrigger,
scheduleTriggerFromForm,
scheduleWeekdaysFromCronField,
} from "./workflowSchedule";
import type { ScheduleFormState } from "./workflowSchedule";
const WEEKDAYS = [
["0", "Sunday", "S"],
["1", "Monday", "M"],
["2", "Tuesday", "T"],
["3", "Wednesday", "W"],
["4", "Thursday", "T"],
["5", "Friday", "F"],
["6", "Saturday", "S"],
] as const;
const MONTH_DAYS = Array.from({ length: 31 }, (_, index) => String(index + 1));
function monthlyDayWarning(monthDay: string): string | null {
return Number(monthDay) > 28
? "This schedule won’t run in some months."
: null;
}
function customCronSeed(schedule: ScheduleFormState): string {
const currentTrigger = scheduleTriggerFromForm(schedule);
if (currentTrigger.cron) return currentTrigger.cron;
switch (currentTrigger.interval) {
case "15m":
return "*/15 * * * *";
case "30m":
return "*/30 * * * *";
case "1h":
return "0 * * * *";
default:
return "";
}
}
export function WorkflowScheduleFields({
disabled,
onUpdate,
trigger,
}: {
disabled?: boolean;
onUpdate: (trigger: TriggerConfig) => void;
trigger: TriggerConfig;
}) {
const [forceCustomCron, setForceCustomCron] = React.useState(false);
const parsedSchedule = scheduleFormFromTrigger(trigger);
const schedule: ScheduleFormState = forceCustomCron
? {
...parsedSchedule,
customCron: trigger.cron ?? parsedSchedule.customCron,
frequency: "custom_cron",
}
: parsedSchedule;
const updateSchedule = (updates: Partial<ScheduleFormState>) => {
onUpdate(scheduleTriggerFromForm({ ...schedule, ...updates }));
};
const usesTime = ["daily", "weekly", "monthly"].includes(schedule.frequency);
const selectedWeekdays = new Set(
scheduleWeekdaysFromCronField(schedule.weekday),
);
const warning = monthlyDayWarning(schedule.monthDay);
return (
<div className="space-y-3">
<fieldset>
<legend className="sr-only">Repeats</legend>
<div className="grid grid-cols-2 gap-2.5">
{SCHEDULE_FREQUENCIES.map((frequency) => {
const id = `wf-trigger-frequency-${frequency}`;
return (
<div
className={cn(
"relative",
frequency === "custom_cron" && "col-span-2",
)}
key={frequency}
>
<input
checked={schedule.frequency === frequency}
className="peer sr-only"
disabled={disabled}
id={id}
name="wf-trigger-frequency"
onChange={() => {
const isCustom = frequency === "custom_cron";
setForceCustomCron(isCustom);
updateSchedule({
customCron: isCustom
? customCronSeed(schedule)
: schedule.customCron,
frequency,
});
}}
type="radio"
value={frequency}
/>
<label
className={cn(
"flex min-h-12 cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color]",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
schedule.frequency === frequency
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
)}
htmlFor={id}
>
{SCHEDULE_FREQUENCY_LABELS[frequency]}
</label>
</div>
);
})}
</div>
</fieldset>
{schedule.frequency === "weekly" ? (
<fieldset className="space-y-1.5">
<legend className="text-xs font-medium text-muted-foreground">
Repeat on
</legend>
<div className="grid grid-cols-7 gap-2">
{WEEKDAYS.map(([value, label, shortLabel]) => {
const id = `wf-trigger-weekday-${value}`;
return (
<div className="relative" key={value}>
<input
aria-label={label}
checked={selectedWeekdays.has(value)}
className="peer sr-only"
disabled={disabled}
id={id}
name="wf-trigger-weekday"
onChange={() => {
const nextWeekdays = new Set(selectedWeekdays);
if (nextWeekdays.has(value)) {
if (nextWeekdays.size === 1) return;
nextWeekdays.delete(value);
} else {
nextWeekdays.add(value);
}
updateSchedule({
weekday: WEEKDAYS.map(([day]) => day)
.filter((day) => nextWeekdays.has(day))
.join(","),
});
}}
type="checkbox"
value={value}
/>
<label
className={cn(
"flex aspect-square cursor-pointer items-center justify-center rounded-full text-xs font-medium transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
selectedWeekdays.has(value)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
htmlFor={id}
>
{shortLabel}
</label>
</div>
);
})}
</div>
</fieldset>
) : null}
{schedule.frequency === "monthly" ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-month-day">Day of month</FieldLabel>
<FormSelect
disabled={disabled}
id="wf-trigger-month-day"
onChange={(monthDay) => updateSchedule({ monthDay })}
value={schedule.monthDay}
>
{MONTH_DAYS.map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</FormSelect>
{warning ? (
<div
className="flex gap-2 rounded-lg border border-warning/30 bg-warning-bg px-3 py-2"
role="status"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-xs leading-5 text-warning">{warning}</p>
</div>
) : null}
</div>
) : null}
{usesTime ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-time">Run time (UTC)</FieldLabel>
<Input
disabled={disabled}
id="wf-trigger-time"
onChange={(event) => updateSchedule({ time: event.target.value })}
type="time"
value={schedule.time}
/>
</div>
) : null}
{schedule.frequency === "custom_cron" ? (
<CronExpressionInput
disabled={disabled}
onChange={(customCron) => updateSchedule({ customCron })}
value={schedule.customCron}
/>
) : null}
{schedule.frequency === "custom_interval" ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-interval">
Existing interval
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id="wf-trigger-interval"
onChange={(event) =>
updateSchedule({ customInterval: event.target.value })
}
value={schedule.customInterval}
/>
<p className="text-xs text-muted-foreground">
Keep this legacy interval or choose a repeat option above. All
schedules use UTC.
</p>
</div>
) : null}
</div>
);
}

View File

@@ -1,17 +1,80 @@
import { Trash2 } from "lucide-react";
import { ChevronRight, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowDurationField } from "./WorkflowDurationField";
import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEditor";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import { ACTION_LABELS, ACTION_TYPES } from "./workflowFormTypes";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
import type {
ActionType,
StepFormState,
TriggerType,
import {
supportsMessageTextCondition,
type StepFormState,
type TriggerType,
} from "./workflowFormTypes";
const DEFAULT_STEP_TIMEOUT_SECONDS = 5 * 60;
type StepSetting = "run-controls" | "details";
function StepSettingAccordion({
children,
disabled,
expanded,
label,
onToggle,
summary,
}: {
children: ReactNode;
disabled?: boolean;
expanded: boolean;
label: string;
onToggle: () => void;
summary: string;
}) {
return (
<div>
<button
aria-expanded={expanded}
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={onToggle}
type="button"
>
<span className="min-w-0 flex-1 truncate text-base font-medium">
{label}
</span>
<span className="max-w-40 truncate text-sm text-muted-foreground">
{summary}
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
</button>
{expanded ? (
<div className="animate-in pb-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
{children}
</div>
) : null}
</div>
);
}
function runControlsSummary(step: StepFormState): string {
const hasCondition = Boolean(step.condition?.trim());
const timeout = step.timeoutSecs?.trim();
if (hasCondition && timeout) return `Conditional · ${timeout}`;
if (hasCondition) return "Conditional";
if (timeout) return timeout;
return "Default";
}
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
switch (action) {
case "send_dm":
@@ -45,12 +108,14 @@ function StepConfigFields({
prefix,
disabled,
triggerType,
workflowChannelId,
onUpdate,
}: {
step: StepFormState;
prefix: string;
disabled?: boolean;
triggerType: TriggerType;
workflowChannelId?: string | null;
onUpdate: (step: StepFormState) => void;
}) {
switch (step.action) {
@@ -87,31 +152,37 @@ function StepConfigFields({
value={step.text ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-channel`}
onChange={(event) =>
onUpdate({ ...step, channel: event.target.value })
}
placeholder="Channel UUID"
value={step.channel ?? ""}
/>
{workflowChannelId ? (
<p className="text-xs text-muted-foreground">
Leave empty to use the trigger channel. Webhook runs and manual
Trigger runs need an explicit channel override.
Messages post to the workflow channel selected above.
</p>
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
) : (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-channel`}
onChange={(event) =>
onUpdate({ ...step, channel: event.target.value })
}
placeholder="Channel UUID"
value={step.channel ?? ""}
/>
<p className="text-xs text-muted-foreground">
Defaults to the channel that triggered the workflow. Webhook and
manual triggers require a channel.
</p>
) : null}
</div>
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
</p>
) : null}
</div>
)}
</div>
);
case "send_dm":
@@ -149,9 +220,9 @@ function StepConfigFields({
);
case "call_webhook":
return (
<div className="space-y-3">
<div className="space-y-4">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-url`}>URL</FieldLabel>
<FieldLabel htmlFor={`${prefix}-url`}>Endpoint URL</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
@@ -169,9 +240,7 @@ function StepConfigFields({
) : null}
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-method`}>
Method (optional)
</FieldLabel>
<FieldLabel htmlFor={`${prefix}-method`}>HTTP method</FieldLabel>
<FormSelect
disabled={disabled}
id={`${prefix}-method`}
@@ -192,7 +261,9 @@ function StepConfigFields({
stepId={step.id || prefix}
/>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-body`}>Body (optional)</FieldLabel>
<FieldLabel htmlFor={`${prefix}-body`}>
Request body (optional)
</FieldLabel>
<Textarea
autoCapitalize="off"
className="min-h-[60px] resize-y font-mono text-xs"
@@ -295,133 +366,143 @@ function StepConfigFields({
}
export function WorkflowStepCard({
bare = false,
showHeader = true,
index,
disabled,
onRemove,
onUpdate,
step,
triggerType,
workflowChannelId,
}: {
bare?: boolean;
showHeader?: boolean;
index: number;
disabled?: boolean;
onRemove: () => void;
onUpdate: (step: StepFormState) => void;
step: StepFormState;
triggerType: TriggerType;
workflowChannelId?: string | null;
}) {
const prefix = `wf-step-${index}`;
const [expandedSetting, setExpandedSetting] = useState<StepSetting | null>(
null,
);
const toggleSetting = (setting: StepSetting) => {
setExpandedSetting((current) => (current === setting ? null : setting));
};
return (
<div className="space-y-3 rounded-lg border border-border/70 bg-muted/10 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Step {index + 1}
</span>
<Button
aria-label="Remove step"
className="h-7 w-7"
disabled={disabled}
onClick={onRemove}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
<Input
autoCapitalize="off"
<div
className={cn(
"space-y-0",
!bare && "rounded-lg border border-border/70 bg-muted/10 p-3",
)}
>
{showHeader ? (
<div className="mb-3 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Step {index + 1}
</span>
<Button
aria-label="Remove step"
className="h-7 w-7"
disabled={disabled}
id={`${prefix}-id`}
onChange={(event) => onUpdate({ ...step, id: event.target.value })}
placeholder="unique_step_id"
value={step.id}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-name`}>
Step name (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-name`}
onChange={(event) =>
onUpdate({ ...step, name: event.target.value })
}
placeholder="Human-friendly label"
value={step.name ?? ""}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-action`}>Action</FieldLabel>
<FormSelect
disabled={disabled}
id={`${prefix}-action`}
onChange={(value) => {
const next = { ...step, action: value as ActionType };
if (value === "call_webhook" && !next.method) {
next.method = "POST";
}
onUpdate(next);
}}
value={step.action}
onClick={onRemove}
size="icon"
type="button"
variant="ghost"
>
{ACTION_TYPES.map((action) => (
<option key={action} value={action}>
{ACTION_LABELS[action]}
</option>
))}
</FormSelect>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
Timeout seconds (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-timeout-secs`}
inputMode="numeric"
onChange={(event) =>
onUpdate({ ...step, timeoutSecs: event.target.value })
}
placeholder="e.g. 300"
value={step.timeoutSecs ?? ""}
/>
</div>
</div>
) : null}
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-condition`}>
Run condition (optional)
</FieldLabel>
<Input
autoCapitalize="off"
<section className="space-y-4 pb-5">
<StepConfigFields
disabled={disabled}
id={`${prefix}-condition`}
onChange={(event) =>
onUpdate({ ...step, condition: event.target.value })
}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={step.condition ?? ""}
onUpdate={onUpdate}
prefix={prefix}
step={step}
triggerType={triggerType}
workflowChannelId={workflowChannelId}
/>
</div>
</section>
<StepConfigFields
disabled={disabled}
onUpdate={onUpdate}
prefix={prefix}
step={step}
triggerType={triggerType}
/>
<section className="divide-y divide-border/50 border-t border-border/50">
<StepSettingAccordion
disabled={disabled}
expanded={expandedSetting === "run-controls"}
label="Run controls"
onToggle={() => toggleSetting("run-controls")}
summary={runControlsSummary(step)}
>
<div className="space-y-4">
{supportsMessageTextCondition(triggerType) ? (
<WorkflowMessageTextCondition
allowAdvanced={false}
disabled={disabled}
onChange={(condition) => onUpdate({ ...step, condition })}
value={step.condition ?? ""}
/>
) : null}
<WorkflowDurationField
disabled={disabled}
fallbackSeconds={DEFAULT_STEP_TIMEOUT_SECONDS}
id={`${prefix}-timeout-secs`}
label="Timeout"
onChange={(timeoutSecs) => onUpdate({ ...step, timeoutSecs })}
placeholder="5m"
value={step.timeoutSecs ?? ""}
/>
</div>
</StepSettingAccordion>
<StepSettingAccordion
disabled={disabled}
expanded={expandedSetting === "details"}
label="Step details"
onToggle={() => toggleSetting("details")}
summary={step.name?.trim() || step.id}
>
<div className="space-y-4">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-name`}>
Name (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-name`}
onChange={(event) =>
onUpdate({ ...step, name: event.target.value })
}
placeholder="e.g. Notify deployment channel"
value={step.name ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-id`}
onChange={(event) =>
onUpdate({ ...step, id: event.target.value })
}
placeholder="unique_step_id"
value={step.id}
/>
<p className="text-xs text-muted-foreground">
Used in configuration and run history.
</p>
</div>
</div>
</StepSettingAccordion>
</section>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Skeleton } from "@/shared/ui/skeleton";
type WorkflowUnavailableDialogProps = {
loading: boolean;
onOpenChange: (open: boolean) => void;
onRetry: () => void;
open: boolean;
};
export function WorkflowUnavailableDialog({
loading,
onOpenChange,
onRetry,
open,
}: WorkflowUnavailableDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
<DialogHeader>
<DialogTitle>
{loading ? "Loading workflow…" : "Workflow unavailable"}
</DialogTitle>
<DialogDescription>
{loading
? "Resolving workflow details."
: "This workflow could not be loaded. It may no longer be available to you."}
</DialogDescription>
</DialogHeader>
{loading ? (
<div
aria-label="Loading workflow"
className="space-y-3"
role="status"
>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
) : (
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
<X className="h-4 w-4" />
Close
</Button>
</DialogClose>
<Button onClick={onRetry} type="button">
Retry
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,37 +1,47 @@
import { Eye, EyeOff } from "lucide-react";
import * as React from "react";
import { CopyButton } from "@/features/agents/ui/CopyButton";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
type WorkflowWebhookSecretDialogProps = {
onOpenChange: (open: boolean) => void;
onContinue: () => void;
open: boolean;
relayHttpUrl: string;
relayHttpUrl: string | null;
relayUrlError: string | null;
webhookSecret: string;
workflowId: string;
};
export function WorkflowWebhookSecretDialog({
onOpenChange,
onContinue,
open,
relayHttpUrl,
relayUrlError,
webhookSecret,
workflowId,
}: WorkflowWebhookSecretDialogProps) {
const webhookUrl = `${relayHttpUrl}/hooks/${workflowId}`;
const [revealed, setRevealed] = React.useState(false);
const webhookUrl = relayHttpUrl
? `${relayHttpUrl}/hooks/${workflowId}`
: null;
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<Dialog onOpenChange={(nextOpen) => !nextOpen && onContinue()} open={open}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Webhook Ready</DialogTitle>
<DialogTitle>Webhook ready</DialogTitle>
<DialogDescription>
This secret is only shown now. If it is lost, re-save the workflow
to generate a new one.
This private secret is shown once and cannot be recovered. Copy and
store it before continuing.
</DialogDescription>
</DialogHeader>
@@ -40,22 +50,56 @@ export function WorkflowWebhookSecretDialog({
<p className="text-xs font-medium text-muted-foreground">
Webhook URL
</p>
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
{webhookUrl}
</pre>
<CopyButton label="Copy URL" value={webhookUrl} />
{webhookUrl ? (
<>
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
{webhookUrl}
</pre>
<CopyButton label="Copy URL" value={webhookUrl} />
</>
) : (
<p
className={
relayUrlError
? "rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
: "rounded-md bg-muted/50 p-3 text-sm text-muted-foreground"
}
>
{relayUrlError
? `The workflow was saved, but its webhook URL could not be loaded: ${relayUrlError}`
: "Loading webhook URL…"}
</p>
)}
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
`X-Webhook-Secret`
</p>
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
{webhookSecret}
</pre>
<div className="flex items-center gap-2 rounded-md bg-muted/50 p-3">
<code className="min-w-0 flex-1 overflow-x-auto font-mono text-xs">
{revealed ? webhookSecret : "•".repeat(24)}
</code>
<Button
aria-label={
revealed ? "Hide webhook secret" : "Reveal webhook secret"
}
onClick={() => setRevealed((value) => !value)}
size="icon-xs"
type="button"
variant="ghost"
>
{revealed ? <EyeOff /> : <Eye />}
</Button>
</div>
<CopyButton label="Copy Secret" value={webhookSecret} />
</div>
</div>
<DialogFooter>
<Button onClick={onContinue} type="button">
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@@ -1,35 +1,40 @@
import * as React from "react";
import type {
WorkflowEditorCreateTarget,
WorkflowEditorWorkflowTarget,
} from "@/features/workflows/ui/WorkflowEditorHost";
import type { Channel } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import type { WorkflowEditorPane } from "./workflowEditorPane";
const WorkflowsView = React.lazy(async () => {
const module = await import("@/features/workflows/ui/WorkflowsView");
return { default: module.WorkflowsView };
});
/** URL-addressable editor target: a shared editor target plus the history
* origin the Workflows route needs to close back to where the user came from. */
export type WorkflowEditorRoute =
| (WorkflowEditorCreateTarget & { hasOrigin: boolean })
| (WorkflowEditorWorkflowTarget & { hasOrigin: boolean });
type WorkflowsScreenProps = {
channels: Channel[];
onCloseWorkflow: () => void;
onSelectWorkflow: (workflowId: string) => void;
selectedWorkflowId: string | null;
editor: WorkflowEditorRoute | null;
onCloseEditor: () => void;
onCreateWorkflow: () => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
onViewWorkflow: (workflowId: string) => void;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
};
export function WorkflowsScreen({
channels,
onCloseWorkflow,
onSelectWorkflow,
selectedWorkflowId,
}: WorkflowsScreenProps) {
export function WorkflowsScreen(props: WorkflowsScreenProps) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsView
channels={channels}
onCloseWorkflow={onCloseWorkflow}
onSelectWorkflow={onSelectWorkflow}
selectedWorkflowId={selectedWorkflowId}
/>
<WorkflowsView {...props} />
</React.Suspense>
</div>
);

View File

@@ -1,8 +1,8 @@
import { Plus, RefreshCw } from "lucide-react";
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { stringify as yamlStringify } from "yaml";
import { toast } from "sonner";
import {
allWorkflowsQueryKey,
@@ -11,8 +11,13 @@ import {
} from "@/features/workflows/hooks";
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel";
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import { WorkflowEditorHost } from "@/features/workflows/ui/WorkflowEditorHost";
import type { WorkflowEditorRoute } from "@/features/workflows/ui/WorkflowsScreen";
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
import {
getWorkflowEnabled,
withWorkflowEnabled,
} from "@/features/workflows/ui/workflowDefinition";
import type { Channel, Workflow } from "@/shared/api/types";
import {
deleteWorkflow,
@@ -23,13 +28,16 @@ import {
import { Button } from "@/shared/ui/button";
import { PageHeader } from "@/shared/ui/PageHeader";
import { Skeleton } from "@/shared/ui/skeleton";
import { getWorkflowEnabled, withWorkflowEnabled } from "./workflowDefinition";
type WorkflowsViewProps = {
channels: Channel[];
onCloseWorkflow: () => void;
onSelectWorkflow: (workflowId: string) => void;
selectedWorkflowId: string | null;
editor: WorkflowEditorRoute | null;
onCloseEditor: () => void;
onCreateWorkflow: () => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
onViewWorkflow: (workflowId: string) => void;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
};
type WorkflowWithChannel = {
@@ -37,30 +45,28 @@ type WorkflowWithChannel = {
channelName: string;
};
type DialogState =
| { mode: "closed" }
| { mode: "create" }
| { mode: "edit"; workflow: Workflow }
| { mode: "duplicate"; workflow: Workflow };
const WORKFLOW_CARD_GRID_CLASS =
"grid grid-cols-1 gap-3 [@container(min-width:38rem)]:grid-cols-2 [@container(min-width:54rem)]:grid-cols-3";
"grid grid-cols-1 gap-3 [@container(min-width:42rem)]:grid-cols-2 [@container(min-width:63rem)]:grid-cols-3";
function WorkflowsListSkeleton() {
return (
<div className={WORKFLOW_CARD_GRID_CLASS}>
{["first", "second", "third", "fourth"].map((card) => (
<div
className="flex min-h-60 flex-col rounded-2xl border bg-card p-5"
className="flex min-h-60 flex-col rounded-2xl bg-muted/50 p-5 shadow-xs"
key={card}
>
<div className="flex items-start justify-between">
<Skeleton className="h-9 w-9 rounded-xl" />
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-9 rounded-xl" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-9 w-9 rounded-xl" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
<Skeleton className="mt-5 h-6 w-3/4" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-4/5" />
<Skeleton className="mt-5 h-3 w-28" />
<Skeleton className="mt-2 h-6 w-full" />
<Skeleton className="mt-2 h-6 w-4/5" />
<Skeleton className="mt-auto h-4 w-32" />
</div>
))}
@@ -84,16 +90,20 @@ function CreateWorkflowCard({ onClick }: { onClick: () => void }) {
export function WorkflowsView({
channels,
onCloseWorkflow,
onSelectWorkflow,
selectedWorkflowId,
editor,
onCloseEditor,
onCreateWorkflow,
onDuplicateWorkflow,
onEditWorkflow,
onViewWorkflow,
onEditorPaneChange,
}: WorkflowsViewProps) {
const [dialogState, setDialogState] = React.useState<DialogState>({
mode: "closed",
});
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const queryClient = useQueryClient();
const editorWorkflowId =
editor && editor.mode !== "create" ? editor.workflowId : null;
const memberChannels = channels.filter((c) => c.isMember);
const channelIds = memberChannels.map((c) => c.id).sort();
const channelIdKey = channelIds.join(",");
@@ -135,10 +145,7 @@ export function WorkflowsView({
const deleteMutation = useMutation({
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
onSuccess: (_data, workflowId) => {
if (selectedWorkflowId === workflowId) {
onCloseWorkflow();
}
onSuccess: () => {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
@@ -190,23 +197,35 @@ export function WorkflowsView({
[],
);
const deleteOne = deleteMutation.mutate;
const deleteOne = deleteMutation.mutateAsync;
const handleConfirmDelete = React.useCallback(
(workflow: Workflow) => {
deleteOne(workflow.id);
setDeleteTarget(null);
async (workflow: Workflow) => {
try {
await deleteOne(workflow.id);
setDeleteTarget(null);
// Deleting the workflow the editor is pointed at would otherwise leave
// that editor open on a workflow that no longer exists.
if (workflow.id === editorWorkflowId) onCloseEditor();
} catch {
// React Query stores the error; keep the confirmation and editor open.
}
},
[deleteOne],
[deleteOne, editorWorkflowId, onCloseEditor],
);
const handleView = React.useCallback(
(workflow: Workflow) => onViewWorkflow(workflow.id),
[onViewWorkflow],
);
const handleEdit = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "edit", workflow }),
[],
(workflow: Workflow) => onEditWorkflow(workflow.id),
[onEditWorkflow],
);
const handleDuplicate = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "duplicate", workflow }),
[],
(workflow: Workflow) => onDuplicateWorkflow(workflow.id),
[onDuplicateWorkflow],
);
const toggleEnabled = toggleEnabledMutation.mutate;
@@ -215,11 +234,9 @@ export function WorkflowsView({
[toggleEnabled],
);
const handleDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setDialogState({ mode: "closed" });
}
}, []);
const editorWorkflowHint = allWorkflows.find(
({ workflow }) => workflow.id === editorWorkflowId,
)?.workflow;
return (
<div
@@ -264,13 +281,10 @@ export function WorkflowsView({
</div>
) : (
<div className={WORKFLOW_CARD_GRID_CLASS}>
<CreateWorkflowCard
onClick={() => setDialogState({ mode: "create" })}
/>
<CreateWorkflowCard onClick={onCreateWorkflow} />
{allWorkflows.map(({ workflow, channelName }) => (
<WorkflowCard
channelName={channelName}
isActive={selectedWorkflowId === workflow.id}
isTogglingEnabled={
toggleEnabledMutation.isPending &&
toggleEnabledMutation.variables?.id === workflow.id
@@ -279,9 +293,9 @@ export function WorkflowsView({
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onEdit={handleEdit}
onSelect={onSelectWorkflow}
onToggleEnabled={handleToggleEnabled}
onTrigger={handleTrigger}
onView={handleView}
workflow={workflow}
/>
))}
@@ -290,33 +304,31 @@ export function WorkflowsView({
</div>
</div>
{selectedWorkflowId ? (
<div className="w-[400px] shrink-0">
<WorkflowDetailPanel
key={selectedWorkflowId}
onClose={onCloseWorkflow}
onEdit={handleEdit}
workflowId={selectedWorkflowId}
/>
</div>
) : null}
<WorkflowDialog
<WorkflowEditorHost
channels={memberChannels}
mode={dialogState.mode === "closed" ? "create" : dialogState.mode}
onOpenChange={handleDialogOpenChange}
open={dialogState.mode !== "closed"}
workflow={
dialogState.mode === "edit" || dialogState.mode === "duplicate"
? dialogState.workflow
: null
}
editor={editor}
onClose={onCloseEditor}
onDeleteWorkflow={handleDelete}
onDuplicateWorkflow={onDuplicateWorkflow}
onEditWorkflow={onEditWorkflow}
onEditorPaneChange={onEditorPaneChange}
onTriggerWorkflow={handleTrigger}
workflowHint={editorWorkflowHint}
/>
<WorkflowDeleteDialog
error={
deleteMutation.error instanceof Error
? deleteMutation.error.message
: null
}
isPending={deleteMutation.isPending}
onConfirm={handleConfirmDelete}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
if (!open) {
deleteMutation.reset();
setDeleteTarget(null);
}
}}
open={deleteTarget !== null}
workflow={deleteTarget}

View File

@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CRON_FIELD_DEFINITIONS,
cronExpressionError,
cronExpressionFromFields,
cronFieldsFromPaste,
validateCronField,
validateCronFields,
} from "./cronExpression.ts";
test("accepts supported five-field cron syntax", () => {
for (const expression of [
"0 9 * * 1-5",
"*/15 * * * *",
"0 */2 1,15 JAN,MAR MON-FRI",
]) {
const result = cronFieldsFromPaste(expression);
assert.equal(result.ok, true);
assert.deepEqual(validateCronFields(result.fields), [
null,
null,
null,
null,
null,
]);
assert.equal(cronExpressionFromFields(result.fields), expression);
assert.equal(cronExpressionError(expression), null);
}
});
test("validates cron field ranges and structure locally", () => {
assert.equal(
validateCronField("60", CRON_FIELD_DEFINITIONS[0]),
"Minute must be between 0 and 59.",
);
assert.equal(
validateCronField("5-2", CRON_FIELD_DEFINITIONS[2]),
"Day range must go from lower to higher.",
);
assert.equal(
validateCronField("*/0", CRON_FIELD_DEFINITIONS[1]),
"Hour step must be a positive whole number.",
);
});
test("whole-expression validation requires exactly five fields", () => {
assert.deepEqual(cronFieldsFromPaste("0 9 * *"), {
error: "Paste a 5-field cron expression. Found 4 fields.",
ok: false,
});
assert.match(cronExpressionError("not-a-cron"), /Found 1 field/);
});

View File

@@ -0,0 +1,156 @@
export const CRON_FIELD_DEFINITIONS = [
{ label: "Minute", max: 59, min: 0 },
{ label: "Hour", max: 23, min: 0 },
{ label: "Day", max: 31, min: 1 },
{
aliases: [
"JAN",
"FEB",
"MAR",
"APR",
"MAY",
"JUN",
"JUL",
"AUG",
"SEP",
"OCT",
"NOV",
"DEC",
],
label: "Month",
max: 12,
min: 1,
},
{
aliases: ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"],
label: "Weekday",
max: 7,
min: 0,
},
] as const;
export type CronFields = [string, string, string, string, string];
export function cronFieldsFromExpression(expression: string): CronFields {
const values = expression.trim() ? expression.trim().split(/\s+/) : [];
return [
values[0] ?? "",
values[1] ?? "",
values[2] ?? "",
values[3] ?? "",
values[4] ?? "",
];
}
export function cronExpressionFromFields(fields: CronFields): string {
return fields.join(" ");
}
export function normalizeCronExpression(expression: string): string {
return expression.trim().replace(/\s+/g, " ");
}
export function cronFieldsFromPaste(
pastedValue: string,
): { fields: CronFields; ok: true } | { error: string; ok: false } {
const values = pastedValue.trim().split(/\s+/);
if (values.length !== CRON_FIELD_DEFINITIONS.length) {
return {
error: `Paste a 5-field cron expression. Found ${values.length} field${values.length === 1 ? "" : "s"}.`,
ok: false,
};
}
return { fields: values as CronFields, ok: true };
}
type CronFieldDefinition = (typeof CRON_FIELD_DEFINITIONS)[number];
function atomError(
atom: string,
definition: CronFieldDefinition,
): string | null {
const upperAtom = atom.toUpperCase();
if (
"aliases" in definition &&
definition.aliases.includes(upperAtom as never)
) {
return null;
}
if (!/^\d+$/.test(atom)) {
return `${definition.label} contains “${atom}”, which is not a supported value.`;
}
const value = Number(atom);
if (value < definition.min || value > definition.max) {
return `${definition.label} must be between ${definition.min} and ${definition.max}.`;
}
return null;
}
function segmentError(
segment: string,
definition: CronFieldDefinition,
): string | null {
const stepParts = segment.split("/");
if (stepParts.length > 2 || stepParts.some((part) => !part)) {
return `${definition.label} has an invalid step.`;
}
const [base, step] = stepParts;
if (step !== undefined) {
if (!/^\d+$/.test(step) || Number(step) < 1) {
return `${definition.label} step must be a positive whole number.`;
}
}
if (base === "*") return null;
const rangeParts = base.split("-");
if (rangeParts.length > 2 || rangeParts.some((part) => !part)) {
return `${definition.label} has an invalid range.`;
}
const startError = atomError(rangeParts[0], definition);
if (startError) return startError;
if (rangeParts.length === 1) return null;
const endError = atomError(rangeParts[1], definition);
if (endError) return endError;
const start = Number(rangeParts[0]);
const end = Number(rangeParts[1]);
if (Number.isFinite(start) && Number.isFinite(end) && start > end) {
return `${definition.label} range must go from lower to higher.`;
}
return null;
}
export function validateCronField(
value: string,
definition: CronFieldDefinition,
): string | null {
if (!value) return `${definition.label} is required.`;
const segments = value.split(",");
if (segments.some((segment) => !segment)) {
return `${definition.label} has an empty list item.`;
}
for (const segment of segments) {
const error = segmentError(segment, definition);
if (error) return error;
}
return null;
}
export function validateCronFields(fields: CronFields): Array<string | null> {
return fields.map((field, index) =>
validateCronField(field, CRON_FIELD_DEFINITIONS[index]),
);
}
export function cronExpressionError(expression: string): string | null {
const parsed = cronFieldsFromPaste(expression);
if (!parsed.ok) return parsed.error;
return validateCronFields(parsed.fields).find(Boolean) ?? null;
}

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DURATION_SLIDER_STOPS,
durationSliderIndex,
formatDurationSeconds,
formatDurationSecondsVerbose,
parseDurationSeconds,
} from "./workflowDuration.ts";
test("parseDurationSeconds accepts compact and combined whole-second durations", () => {
assert.equal(parseDurationSeconds("5s"), 5);
assert.equal(parseDurationSeconds("90m"), 5_400);
assert.equal(parseDurationSeconds("1h 2s"), 3_602);
assert.equal(parseDurationSeconds("1H2M3S"), 3_723);
assert.equal(parseDurationSeconds("2d"), 172_800);
assert.equal(parseDurationSeconds("2w 3d 4h 5m 6s"), 1_483_506);
assert.equal(parseDurationSeconds("42"), 42);
assert.equal(parseDurationSeconds("0s"), 0);
});
test("parseDurationSeconds rejects empty, malformed, and fractional values", () => {
assert.equal(parseDurationSeconds(""), null);
assert.equal(parseDurationSeconds("1.5m"), null);
assert.equal(parseDurationSeconds("1m later"), null);
});
test("formatDurationSeconds produces compact labels with significant units", () => {
assert.equal(formatDurationSeconds(0), "0s");
assert.equal(formatDurationSeconds(5), "5s");
assert.equal(formatDurationSeconds(62), "1m 2s");
assert.equal(formatDurationSeconds(3_602), "1h 2s");
assert.equal(formatDurationSeconds(7_323), "2h 2m 3s");
assert.equal(formatDurationSeconds(172_800), "2d");
assert.equal(formatDurationSeconds(1_483_506), "2w 3d 4h 5m 6s");
});
test("formatDurationSecondsVerbose spells out units with correct plurals", () => {
assert.equal(formatDurationSecondsVerbose(0), "0 seconds");
assert.equal(formatDurationSecondsVerbose(300), "5 minutes");
assert.equal(formatDurationSecondsVerbose(604_800), "1 week");
assert.equal(
formatDurationSecondsVerbose(1_483_506),
"2 weeks 3 days 4 hours 5 minutes 6 seconds",
);
});
test("duration slider starts at one second, keeps fine short-delay stops, and reaches three hours", () => {
assert.deepEqual(DURATION_SLIDER_STOPS.slice(0, 3), [1, 2, 3]);
assert.equal(DURATION_SLIDER_STOPS.at(-1), 10_800);
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(62)], 62);
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(300)], 300);
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(3_602)], 3_600);
});

View File

@@ -0,0 +1,127 @@
const DURATION_PARTS_PATTERN =
/^\s*(?:(\d+)\s*w)?\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?\s*$/i;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
const SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY;
/** Parse compact durations such as `5s`, `1h 2s`, `2d`, or `3w`. */
export function parseDurationSeconds(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) {
const seconds = Number(trimmed);
return Number.isSafeInteger(seconds) ? seconds : null;
}
const match = DURATION_PARTS_PATTERN.exec(trimmed);
if (!match || match.slice(1).every((part) => part === undefined)) return null;
const weeks = Number(match[1] ?? 0);
const days = Number(match[2] ?? 0);
const hours = Number(match[3] ?? 0);
const minutes = Number(match[4] ?? 0);
const seconds = Number(match[5] ?? 0);
const total =
weeks * SECONDS_PER_WEEK +
days * SECONDS_PER_DAY +
hours * SECONDS_PER_HOUR +
minutes * SECONDS_PER_MINUTE +
seconds;
return Number.isSafeInteger(total) ? total : null;
}
/** Format whole seconds as a compact duration, omitting empty units. */
export function formatDurationSeconds(totalSeconds: number): string {
if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
if (totalSeconds === 0) return "0s";
const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
const minutes = Math.floor(
(totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
);
const seconds = totalSeconds % SECONDS_PER_MINUTE;
const parts: string[] = [];
if (weeks > 0) parts.push(`${weeks}w`);
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (seconds > 0) parts.push(`${seconds}s`);
return parts.join(" ");
}
function verboseUnit(value: number, unit: string): string {
return `${value} ${unit}${value === 1 ? "" : "s"}`;
}
/** Format whole seconds with fully spelled-out units for summary UI. */
export function formatDurationSecondsVerbose(totalSeconds: number): string {
if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
if (totalSeconds === 0) return "0 seconds";
const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
const minutes = Math.floor(
(totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
);
const seconds = totalSeconds % SECONDS_PER_MINUTE;
const parts: string[] = [];
if (weeks > 0) parts.push(verboseUnit(weeks, "week"));
if (days > 0) parts.push(verboseUnit(days, "day"));
if (hours > 0) parts.push(verboseUnit(hours, "hour"));
if (minutes > 0) parts.push(verboseUnit(minutes, "minute"));
if (seconds > 0) parts.push(verboseUnit(seconds, "second"));
return parts.join(" ");
}
function steppedRange(start: number, end: number, step: number): number[] {
const values: number[] = [];
for (let value = start; value <= end; value += step) values.push(value);
return values;
}
/**
* Slider stops favor the short delays people use most, then relax precision as
* the duration grows. The typed field still accepts exact values between stops.
*/
export const DURATION_SLIDER_STOPS = [
...steppedRange(1, 120, 1),
...steppedRange(125, 600, 5),
...steppedRange(615, 1_800, 15),
...steppedRange(1_860, 7_200, 60),
...steppedRange(7_500, 10_800, 300),
];
export const DEFAULT_DURATION_SECONDS = 1;
export function durationSliderIndex(totalSeconds: number): number {
if (totalSeconds <= DURATION_SLIDER_STOPS[0]) return 0;
const lastIndex = DURATION_SLIDER_STOPS.length - 1;
if (totalSeconds >= DURATION_SLIDER_STOPS[lastIndex]) return lastIndex;
let low = 0;
let high = lastIndex;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const value = DURATION_SLIDER_STOPS[middle];
if (value === totalSeconds) return middle;
if (value < totalSeconds) low = middle + 1;
else high = middle - 1;
}
return totalSeconds - DURATION_SLIDER_STOPS[high] <=
DURATION_SLIDER_STOPS[low] - totalSeconds
? high
: low;
}

View File

@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import ts from "typescript";
const sourcePath = new URL("./workflowEditorPane.ts", import.meta.url);
const source = await import("node:fs/promises").then((fs) =>
fs.readFile(sourcePath, "utf8"),
);
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(compiled).toString("base64")}`;
const { parseWorkflowEditorPane, serializeWorkflowEditorPane } = await import(
moduleUrl
);
test("parses trigger and stable step-id panes", () => {
assert.deepEqual(parseWorkflowEditorPane("trigger"), { type: "trigger" });
assert.deepEqual(parseWorkflowEditorPane("step:notify_team"), {
type: "step",
stepId: "notify_team",
});
const maxId = "a".repeat(64);
assert.deepEqual(parseWorkflowEditorPane(`step:${maxId}`), {
type: "step",
stepId: maxId,
});
});
test("rejects malformed pane parameters", () => {
for (const value of [
undefined,
null,
"",
"step:",
"step:%E0%A4%A",
"step-0",
"step:notify%20team",
"step:bad-id",
`step:${"a".repeat(65)}`,
`step:${"%41".repeat(65)}`,
])
assert.equal(parseWorkflowEditorPane(value), null);
});
test("serializes stable step-id panes", () => {
assert.equal(serializeWorkflowEditorPane(null), undefined);
assert.equal(serializeWorkflowEditorPane({ type: "trigger" }), "trigger");
assert.equal(
serializeWorkflowEditorPane({ type: "step", stepId: "notify_team" }),
"step:notify_team",
);
assert.equal(
serializeWorkflowEditorPane({ type: "step", stepId: "bad-id" }),
undefined,
);
});

View File

@@ -0,0 +1,32 @@
export type WorkflowEditorPane =
| { type: "trigger" }
| { type: "step"; stepId: string }
| null;
const STEP_PANE_PREFIX = "step:";
const STEP_ID_PATTERN = /^[A-Za-z0-9_]{1,64}$/;
export function parseWorkflowEditorPane(value: unknown): WorkflowEditorPane {
if (value === "trigger") return { type: "trigger" };
if (typeof value !== "string" || !value.startsWith(STEP_PANE_PREFIX)) {
return null;
}
const encodedStepId = value.slice(STEP_PANE_PREFIX.length);
if (!encodedStepId || encodedStepId.length > 192) return null;
try {
const stepId = decodeURIComponent(encodedStepId);
return STEP_ID_PATTERN.test(stepId) ? { type: "step", stepId } : null;
} catch {
return null;
}
}
export function serializeWorkflowEditorPane(
pane: WorkflowEditorPane,
): string | undefined {
if (pane === null) return undefined;
if (pane.type === "trigger") return "trigger";
if (!STEP_ID_PATTERN.test(pane.stepId)) return undefined;
return `${STEP_PANE_PREFIX}${encodeURIComponent(pane.stepId)}`;
}

View File

@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parse as parseYaml } from "yaml";
import {
formStateToYaml,
supportsMessageTextCondition,
yamlToFormState,
} from "./workflowFormTypes.ts";
function accepted(yaml) {
const result = yamlToFormState(yaml);
assert.equal(result.ok, true, result.ok ? undefined : result.error);
return result.state;
}
function normalizeBackendDefaults(value) {
const copy = structuredClone(value);
if (copy.enabled === undefined) copy.enabled = true;
for (const step of copy.steps ?? []) {
if (step.action === "call_webhook" && step.method === undefined) {
step.method = "POST";
}
}
return copy;
}
test("message-text conditions are limited to message-bearing triggers", () => {
assert.equal(supportsMessageTextCondition("message_posted"), true);
assert.equal(supportsMessageTextCondition("diff_posted"), true);
assert.equal(supportsMessageTextCondition("reaction_added"), false);
assert.equal(supportsMessageTextCondition("webhook"), false);
assert.equal(supportsMessageTextCondition("schedule"), false);
});
const acceptedFixtures = [
`name: Notify\ntrigger:\n on: message_posted\nsteps:\n - id: notify_1\n action: send_message\n text: hello\n`,
`name: React\ndescription: React to a message\nenabled: false\ntrigger:\n on: reaction_added\n emoji: eyes\n filter: trigger_message_id == "abc123"\nsteps:\n - id: react\n name: Add reaction\n timeout_secs: 30\n action: add_reaction\n emoji: white_check_mark\n`,
`name: Webhook\ntrigger:\n on: webhook\nsteps:\n - id: call\n action: call_webhook\n url: https://example.com/hook\n method: PATCH\n headers:\n Authorization: secret\n X-Trace: trace\n body: '{"ok":true}'\n`,
`name: Legacy actions\ntrigger:\n on: diff_posted\n filter: str_contains(trigger_text, "deploy")\nsteps:\n - id: dm\n action: send_dm\n to: abc123\n text: hello\n - id: approval\n action: request_approval\n from: manager\n message: Approve?\n timeout: 24h\n - id: topic\n action: set_channel_topic\n topic: Deployed\n - id: wait\n action: delay\n duration: 5m\n`,
`name: Scheduled preset\ntrigger:\n on: schedule\n interval: 15m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
`name: Scheduled custom\ntrigger:\n on: schedule\n cron: 0 */2 * * 1,3,5\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
`name: Scheduled legacy interval\ntrigger:\n on: schedule\n interval: 2h30m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
];
test("accepted Form fixtures survive a semantic YAML round trip", () => {
for (const fixture of acceptedFixtures) {
const generated = formStateToYaml(accepted(fixture));
assert.deepEqual(
normalizeBackendDefaults(parseYaml(generated)),
normalizeBackendDefaults(parseYaml(fixture)),
);
}
});
test("recognized nodes with unknown fields are refused without touching YAML", () => {
const fixtures = [
`name: Test\nunknown: true\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: message_posted, future_filter: x }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, retry: 3 }]\n`,
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, auth: bearer }]\n`,
];
for (const yaml of fixtures) {
const original = yaml;
const result = yamlToFormState(yaml);
assert.equal(result.ok, false);
assert.match(result.error, /YAML editor/);
assert.equal(yaml, original);
}
});
test("invalid IDs, shapes, and scalar types are refused", () => {
const cases = [
[
"missing ID",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ action: send_message, text: hi }]\n`,
],
[
"duplicate ID",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: same, action: send_message, text: hi }, { id: same, action: delay, duration: 5m }]\n`,
],
[
"invalid ID",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: bad-id, action: send_message, text: hi }]\n`,
],
[
"oversize ID",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: ${"a".repeat(65)}, action: send_message, text: hi }]\n`,
],
[
"steps object",
`name: Test\ntrigger: { on: webhook }\nsteps: { id: s1, action: send_message, text: hi }\n`,
],
[
"missing required action field",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message }]\n`,
],
[
"numeric text",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: 42 }]\n`,
],
[
"numeric header",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { X-Retry: 3 } }]\n`,
],
[
"zero timeout",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 0, action: send_message, text: hi }]\n`,
],
[
"fractional timeout",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 1.5, action: send_message, text: hi }]\n`,
],
[
"unsupported method",
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, method: OPTIONS }]\n`,
],
];
for (const [name, yaml] of cases) {
assert.equal(yamlToFormState(yaml).ok, false, name);
}
});
test("step condition capabilities stay in YAML mode", () => {
const condition = `name: Conditional\ntrigger: { on: webhook }\nsteps: [{ id: s1, if: trigger_author == "abc", action: send_message, text: hi }]\n`;
const conditionResult = yamlToFormState(condition);
assert.equal(conditionResult.ok, false);
assert.match(conditionResult.error, /conditions.*YAML editor/);
});
test("malformed and unowned schedule definitions stay losslessly in YAML mode", () => {
const fixtures = [
`name: Missing\ntrigger: { on: schedule }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Both\ntrigger: { on: schedule, cron: "0 9 * * *", interval: 1h }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Numeric\ntrigger: { on: schedule, interval: 30 }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Unknown\ntrigger: { on: schedule, cron: "0 9 * * *", timezone: UTC }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Invalid cron\ntrigger: { on: schedule, cron: "60 9 * * *" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
];
for (const yaml of fixtures) {
const original = yaml;
const result = yamlToFormState(yaml);
assert.equal(result.ok, false);
assert.match(result.error, /YAML editor/);
assert.equal(yaml, original);
}
});
test("the serializer emits only one schedule representation", () => {
const yaml = formStateToYaml({
name: "Exclusive",
description: "",
enabled: true,
trigger: { on: "schedule", cron: "0 9 * * *", interval: "1h" },
steps: [{ id: "s1", action: "send_message", text: "hi" }],
});
assert.deepEqual(parseYaml(yaml).trigger, {
on: "schedule",
cron: "0 9 * * *",
});
});
test("presents step timeout seconds as durations and serializes them numerically", () => {
const yaml = `name: Timed\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 3602, action: send_message, text: hi }]\n`;
const state = accepted(yaml);
assert.equal(state.steps[0].timeoutSecs, "1h 2s");
state.steps[0].timeoutSecs = "5m";
assert.equal(parseYaml(formStateToYaml(state)).steps[0].timeout_secs, 300);
});
test("advanced message expressions survive unrelated Form serialization", () => {
const filter =
'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
const yaml = `name: Advanced\ndescription: Before\ntrigger:\n on: message_posted\n filter: '${filter}'\nsteps:\n - id: s1\n action: send_message\n text: hi\n`;
const state = accepted(yaml);
state.description = "After";
const generated = parseYaml(formStateToYaml(state));
assert.equal(generated.description, "After");
assert.equal(generated.trigger.filter, filter);
});
test("values the Form serializer would normalize are refused", () => {
const fixtures = [
`name: Test\ndescription: " spaced "\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ndescription: ""\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: reaction_added, emoji: "" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, name: " spaced ", action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, channel: "" }]\n`,
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { " padded ": value } }]\n`,
];
for (const yaml of fixtures) assert.equal(yamlToFormState(yaml).ok, false);
});

View File

@@ -1,5 +1,11 @@
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
import { cronExpressionError } from "./cronExpression";
import {
formatDurationSeconds,
parseDurationSeconds,
} from "./workflowDuration";
export const TRIGGER_TYPES = [
"message_posted",
"reaction_added",
@@ -9,6 +15,20 @@ export const TRIGGER_TYPES = [
] as const;
export type TriggerType = (typeof TRIGGER_TYPES)[number];
export function supportsMessageTextCondition(
triggerType: TriggerType,
): boolean {
return triggerType === "message_posted" || triggerType === "diff_posted";
}
export const SELECTABLE_TRIGGER_TYPES = [
"message_posted",
"reaction_added",
"diff_posted",
"webhook",
"schedule",
] as const satisfies readonly TriggerType[];
export const ACTION_TYPES = [
"delay",
"send_message",
@@ -20,6 +40,12 @@ export const ACTION_TYPES = [
] as const;
export type ActionType = (typeof ACTION_TYPES)[number];
export const SELECTABLE_ACTION_TYPES = [
"send_message",
"delay",
"call_webhook",
] as const satisfies readonly ActionType[];
export type TriggerConfig = {
on: TriggerType;
filter?: string;
@@ -121,10 +147,8 @@ function headersToRecord(
function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
if (!timeoutSecs) return undefined;
const trimmed = timeoutSecs.trim();
if (!/^\d+$/.test(trimmed)) return undefined;
const parsed = Number(trimmed);
return parsed > 0 ? parsed : undefined;
const parsed = parseDurationSeconds(timeoutSecs);
return parsed !== null && parsed > 0 ? parsed : undefined;
}
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
@@ -174,7 +198,8 @@ export function formStateToYaml(state: WorkflowFormState): string {
const trigger: Record<string, unknown> = { on: state.trigger.on };
if (
(state.trigger.on === "message_posted" ||
state.trigger.on === "diff_posted") &&
state.trigger.on === "diff_posted" ||
state.trigger.on === "reaction_added") &&
state.trigger.filter
) {
trigger.filter = state.trigger.filter;
@@ -183,8 +208,11 @@ export function formStateToYaml(state: WorkflowFormState): string {
trigger.emoji = state.trigger.emoji;
}
if (state.trigger.on === "schedule") {
if (state.trigger.cron) trigger.cron = state.trigger.cron;
if (state.trigger.interval) trigger.interval = state.trigger.interval;
if (state.trigger.cron) {
trigger.cron = state.trigger.cron;
} else if (state.trigger.interval) {
trigger.interval = state.trigger.interval;
}
}
const steps = state.steps.map((step) => ({
@@ -223,78 +251,352 @@ export function nextStepId(existingSteps: StepFormState[]): string {
return `step_${n}`;
}
const TOP_LEVEL_KEYS = new Set([
"name",
"description",
"enabled",
"trigger",
"steps",
]);
const TRIGGER_KEYS: Record<TriggerType, ReadonlySet<string>> = {
message_posted: new Set(["on", "filter"]),
reaction_added: new Set(["on", "emoji", "filter"]),
diff_posted: new Set(["on", "filter"]),
webhook: new Set(["on"]),
schedule: new Set(["on", "cron", "interval"]),
};
const COMMON_STEP_KEYS = ["id", "name", "action", "if", "timeout_secs"];
const ACTION_STEP_KEYS: Record<ActionType, ReadonlySet<string>> = {
delay: new Set([...COMMON_STEP_KEYS, "duration"]),
send_message: new Set([...COMMON_STEP_KEYS, "text", "channel"]),
send_dm: new Set([...COMMON_STEP_KEYS, "to", "text"]),
call_webhook: new Set([
...COMMON_STEP_KEYS,
"url",
"method",
"headers",
"body",
]),
request_approval: new Set([
...COMMON_STEP_KEYS,
"from",
"message",
"timeout",
]),
add_reaction: new Set([...COMMON_STEP_KEYS, "emoji"]),
set_channel_topic: new Set([...COMMON_STEP_KEYS, "topic"]),
};
const REQUIRED_ACTION_STRING_KEYS: Record<ActionType, readonly string[]> = {
delay: ["duration"],
send_message: ["text"],
send_dm: ["to", "text"],
call_webhook: ["url"],
request_approval: ["from", "message"],
add_reaction: ["emoji"],
set_channel_topic: ["topic"],
};
const OPTIONAL_ACTION_STRING_KEYS: Record<ActionType, readonly string[]> = {
delay: [],
send_message: ["channel"],
send_dm: [],
call_webhook: ["method", "body"],
request_approval: ["timeout"],
add_reaction: [],
set_channel_topic: [],
};
const WEBHOOK_METHODS = new Set(["POST", "GET", "PUT", "PATCH", "DELETE"]);
const STEP_ID_PATTERN_STRICT = /^[A-Za-z0-9_]{1,64}$/;
type UnknownRecord = Record<string, unknown>;
function objectRecord(value: unknown): UnknownRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as UnknownRecord)
: null;
}
function unknownKey(
record: UnknownRecord,
allowed: ReadonlySet<string>,
): string | null {
return Object.keys(record).find((key) => !allowed.has(key)) ?? null;
}
function requireNonEmptyString(
record: UnknownRecord,
key: string,
label: string,
): string | { error: string } {
const value = record[key];
if (typeof value !== "string" || value.length === 0) {
return { error: `${label} must be a non-empty string` };
}
return value;
}
function optionalOwnedStringError(
record: UnknownRecord,
key: string,
label: string,
): string | null {
const value = record[key];
if (value === undefined) return null;
if (typeof value !== "string") return `${label} must be a string`;
if (value.length === 0) {
return `${label} cannot be empty in Form mode — use the YAML editor`;
}
return null;
}
export function yamlToFormState(
yaml: string,
): { ok: true; state: WorkflowFormState } | { ok: false; error: string } {
try {
const parsed = yamlParse(yaml);
if (!parsed || typeof parsed !== "object") {
return { ok: false, error: "YAML must be an object" };
}
const parsed = objectRecord(yamlParse(yaml));
if (!parsed) return { ok: false, error: "YAML must be an object" };
const triggerOn = parsed.trigger?.on;
if (triggerOn && !TRIGGER_TYPES.includes(triggerOn as TriggerType)) {
const topUnknown = unknownKey(parsed, TOP_LEVEL_KEYS);
if (topUnknown) {
return {
ok: false,
error: `Unsupported trigger type "${triggerOn}" — use the YAML editor`,
error: `Unsupported workflow field "${topUnknown}" — use the YAML editor`,
};
}
const trigger: TriggerConfig = {
on: (triggerOn as TriggerType) ?? TRIGGER_TYPES[0],
filter: parsed.trigger?.filter ?? undefined,
emoji: parsed.trigger?.emoji ?? undefined,
cron: parsed.trigger?.cron ?? undefined,
interval: parsed.trigger?.interval ?? undefined,
};
const rawSteps = parsed.steps ?? [];
if (!Array.isArray(rawSteps)) {
return { ok: false, error: "steps must be a list" };
if (typeof parsed.name !== "string") {
return { ok: false, error: "name must be a string" };
}
for (const step of rawSteps) {
if (step.action && !ACTION_TYPES.includes(step.action as ActionType)) {
if (parsed.description !== undefined) {
if (typeof parsed.description !== "string") {
return { ok: false, error: "description must be a string" };
}
if (
parsed.description.length === 0 ||
parsed.description.trim() !== parsed.description
) {
return {
ok: false,
error: `Unsupported action type "${step.action}" — use the YAML editor`,
error:
"description cannot be empty or have surrounding whitespace in Form mode — use the YAML editor",
};
}
}
if (parsed.enabled !== undefined && typeof parsed.enabled !== "boolean") {
return { ok: false, error: "enabled must be a boolean" };
}
const steps: StepFormState[] = rawSteps.map(
(step: Record<string, unknown>, index: number) => ({
id: (step.id as string) ?? `step_${index + 1}`,
const rawTrigger = objectRecord(parsed.trigger);
if (!rawTrigger || typeof rawTrigger.on !== "string") {
return { ok: false, error: "trigger.on is required" };
}
if (!TRIGGER_TYPES.includes(rawTrigger.on as TriggerType)) {
return {
ok: false,
error: `Unsupported trigger type "${rawTrigger.on}" — use the YAML editor`,
};
}
const triggerOn = rawTrigger.on as TriggerType;
const triggerUnknown = unknownKey(rawTrigger, TRIGGER_KEYS[triggerOn]);
if (triggerUnknown) {
return {
ok: false,
error: `Unsupported ${triggerOn} trigger field "${triggerUnknown}" — use the YAML editor`,
};
}
for (const key of ["filter", "emoji", "cron", "interval"] as const) {
const error = optionalOwnedStringError(rawTrigger, key, `trigger.${key}`);
if (error) {
return {
ok: false,
error:
triggerOn === "schedule" && !error.includes("YAML editor")
? `${error} — use the YAML editor`
: error,
};
}
}
if (triggerOn === "schedule") {
const hasCron = rawTrigger.cron !== undefined;
const hasInterval = rawTrigger.interval !== undefined;
if (hasCron === hasInterval) {
return {
ok: false,
error: hasCron
? "Schedule triggers cannot specify both cron and interval — use the YAML editor"
: "Schedule triggers require either cron or interval — use the YAML editor",
};
}
if (typeof rawTrigger.cron === "string") {
const error = cronExpressionError(rawTrigger.cron);
if (error) {
return {
ok: false,
error: `Unsupported cron expression: ${error} Use the YAML editor`,
};
}
}
}
const trigger: TriggerConfig = {
on: triggerOn,
filter: rawTrigger.filter as string | undefined,
emoji: rawTrigger.emoji as string | undefined,
cron: rawTrigger.cron as string | undefined,
interval: rawTrigger.interval as string | undefined,
};
if (!Array.isArray(parsed.steps)) {
return { ok: false, error: "steps must be a list" };
}
const ids = new Set<string>();
const steps: StepFormState[] = [];
for (const [index, value] of parsed.steps.entries()) {
const number = index + 1;
const step = objectRecord(value);
if (!step)
return { ok: false, error: `Step ${number} must be an object` };
if (
typeof step.id !== "string" ||
!STEP_ID_PATTERN_STRICT.test(step.id)
) {
return {
ok: false,
error: `Step ${number} requires a unique 1–64 character alphanumeric or underscore ID`,
};
}
if (ids.has(step.id)) {
return {
ok: false,
error: `Duplicate step ID "${step.id}" — use the YAML editor`,
};
}
ids.add(step.id);
if (
typeof step.action !== "string" ||
!ACTION_TYPES.includes(step.action as ActionType)
) {
return {
ok: false,
error: `Unsupported action type "${String(step.action)}" — use the YAML editor`,
};
}
const action = step.action as ActionType;
const stepUnknown = unknownKey(step, ACTION_STEP_KEYS[action]);
if (stepUnknown) {
return {
ok: false,
error: `Unsupported ${action} step field "${stepUnknown}" — use the YAML editor`,
};
}
if (step.if !== undefined) {
return {
ok: false,
error: "Step conditions are only available in the YAML editor",
};
}
const nameError = optionalOwnedStringError(
step,
"name",
`Step ${number} name`,
);
if (nameError) return { ok: false, error: nameError };
if (typeof step.name === "string" && step.name.trim() !== step.name) {
return {
ok: false,
error: `Step ${number} name has surrounding whitespace — use the YAML editor`,
};
}
if (
step.timeout_secs !== undefined &&
(!Number.isSafeInteger(step.timeout_secs) ||
(step.timeout_secs as number) <= 0)
) {
return {
ok: false,
error: `Step ${number} timeout_secs must be a positive integer`,
};
}
for (const key of REQUIRED_ACTION_STRING_KEYS[action]) {
const required = requireNonEmptyString(
step,
key,
`Step ${number} ${key}`,
);
if (typeof required !== "string")
return { ok: false, error: required.error };
}
for (const key of OPTIONAL_ACTION_STRING_KEYS[action]) {
const error = optionalOwnedStringError(
step,
key,
`Step ${number} ${key}`,
);
if (error) return { ok: false, error };
}
if (
action === "call_webhook" &&
step.method !== undefined &&
!WEBHOOK_METHODS.has(step.method as string)
) {
return {
ok: false,
error: `Unsupported webhook method "${String(step.method)}" — use the YAML editor`,
};
}
if (step.headers !== undefined) {
const headers = objectRecord(step.headers);
if (
!headers ||
Object.keys(headers).length === 0 ||
Object.values(headers).some((header) => typeof header !== "string")
) {
return {
ok: false,
error:
"Webhook headers must be a non-empty object containing string values",
};
}
const unsafeHeader = Object.keys(headers).find(
(key) => key.length === 0 || key.trim() !== key,
);
if (unsafeHeader !== undefined) {
return {
ok: false,
error:
"Webhook header names cannot be empty or have surrounding whitespace in Form mode",
};
}
}
steps.push({
id: step.id,
name: step.name as string | undefined,
action: (step.action as ActionType) ?? ACTION_TYPES[0],
condition: step.if as string | undefined,
action,
timeoutSecs:
step.timeout_secs !== undefined
? String(step.timeout_secs)
: undefined,
step.timeout_secs === undefined
? undefined
: formatDurationSeconds(step.timeout_secs as number),
duration: step.duration as string | undefined,
text: step.text as string | undefined,
channel: step.channel as string | undefined,
to: step.to as string | undefined,
url: step.url as string | undefined,
method: step.method as string | undefined,
headers: toHeaderRows(
step.headers,
(step.id as string) ?? `step_${index + 1}`,
),
headers: toHeaderRows(step.headers, step.id),
body: step.body as string | undefined,
emoji: step.emoji as string | undefined,
topic: step.topic as string | undefined,
from: step.from as string | undefined,
message: step.message as string | undefined,
timeout: step.timeout as string | undefined,
}),
);
});
}
return {
ok: true,
state: {
name: (parsed.name as string) ?? "",
description: (parsed.description as string) ?? "",
name: parsed.name,
description: (parsed.description as string | undefined) ?? "",
enabled: parsed.enabled !== false,
trigger,
steps,

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MESSAGE_TEXT_CONDITION_OPERATORS,
buildMessageTextCondition,
parseMessageTextCondition,
} from "./workflowMessageTextCondition.ts";
test("builds and parses every structured message-text condition", () => {
for (const operator of MESSAGE_TEXT_CONDITION_OPERATORS) {
const condition = { operator, value: "deploy" };
const expression = buildMessageTextCondition(condition);
assert.notEqual(expression, null);
assert.deepEqual(parseMessageTextCondition(expression), {
operator,
value:
operator === "is_not_empty" || operator === "is_empty" ? "" : "deploy",
});
}
});
test("escapes and restores evalexpr quote and backslash literals", () => {
const condition = {
operator: "equals",
value: 'say "hello"\\world',
};
const expression = buildMessageTextCondition(condition);
assert.equal(expression, 'trigger_text == "say \\"hello\\"\\\\world"');
assert.deepEqual(parseMessageTextCondition(expression), condition);
});
test("parses only exact expressions emitted by the structured form", () => {
for (const expression of [
' str_contains(trigger_text, "deploy")',
'str_contains(trigger_text,"deploy")',
'str_contains(other_text, "deploy")',
'str_contains(trigger_text, "one") && trigger_author == "abc"',
'trigger_text == "bad\\nvalue"',
]) {
assert.equal(parseMessageTextCondition(expression), null, expression);
}
});
test("does not build value operators until text is present", () => {
assert.equal(
buildMessageTextCondition({ operator: "contains", value: " " }),
null,
);
assert.equal(
buildMessageTextCondition({ operator: "is_empty", value: "ignored" }),
"str_len(trigger_text) == 0",
);
});

View File

@@ -0,0 +1,117 @@
export const MESSAGE_TEXT_CONDITION_OPERATORS = [
"contains",
"not_contains",
"starts_with",
"ends_with",
"equals",
"not_equals",
"is_not_empty",
"is_empty",
] as const;
export type MessageTextConditionOperator =
(typeof MESSAGE_TEXT_CONDITION_OPERATORS)[number];
export type MessageTextCondition = {
operator: MessageTextConditionOperator;
value: string;
};
export const MESSAGE_TEXT_CONDITION_LABELS: Record<
MessageTextConditionOperator,
string
> = {
contains: "Contains",
not_contains: "Does not contain",
starts_with: "Starts with",
ends_with: "Ends with",
equals: "Equals",
not_equals: "Does not equal",
is_not_empty: "Has text",
is_empty: "Has no text",
};
export function messageTextConditionNeedsValue(
operator: MessageTextConditionOperator,
): boolean {
return operator !== "is_not_empty" && operator !== "is_empty";
}
function escapeEvalexprString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
export function buildMessageTextCondition(
condition: MessageTextCondition,
): string | null {
if (condition.operator === "is_not_empty") {
return "str_len(trigger_text) > 0";
}
if (condition.operator === "is_empty") {
return "str_len(trigger_text) == 0";
}
if (!condition.value.trim()) return null;
const quotedValue = `"${escapeEvalexprString(condition.value)}"`;
switch (condition.operator) {
case "contains":
return `str_contains(trigger_text, ${quotedValue})`;
case "not_contains":
return `!str_contains(trigger_text, ${quotedValue})`;
case "starts_with":
return `str_starts_with(trigger_text, ${quotedValue})`;
case "ends_with":
return `str_ends_with(trigger_text, ${quotedValue})`;
case "equals":
return `trigger_text == ${quotedValue}`;
case "not_equals":
return `trigger_text != ${quotedValue}`;
}
}
function unescapeEvalexprString(value: string): string {
return value.replaceAll(/\\(["\\])/g, "$1");
}
/** Parse only the exact expression shapes emitted by the structured editor. */
export function parseMessageTextCondition(
expression: string,
): MessageTextCondition | null {
let parsed: MessageTextCondition | null = null;
if (expression === "str_len(trigger_text) > 0") {
parsed = { operator: "is_not_empty", value: "" };
} else if (expression === "str_len(trigger_text) == 0") {
parsed = { operator: "is_empty", value: "" };
} else {
const stringLiteral = '"((?:\\\\["\\\\]|[^"\\\\])*)"';
const functionMatch = new RegExp(
`^(!)?str_(contains|starts_with|ends_with)\\(trigger_text, ${stringLiteral}\\)$`,
).exec(expression);
if (functionMatch) {
parsed = {
operator: functionMatch[1]
? "not_contains"
: functionMatch[2] === "starts_with"
? "starts_with"
: functionMatch[2] === "ends_with"
? "ends_with"
: "contains",
value: unescapeEvalexprString(functionMatch[3]),
};
} else {
const equalityMatch = new RegExp(
`^trigger_text (!=|==) ${stringLiteral}$`,
).exec(expression);
if (equalityMatch) {
parsed = {
operator: equalityMatch[1] === "==" ? "equals" : "not_equals",
value: unescapeEvalexprString(equalityMatch[2]),
};
}
}
}
return parsed && buildMessageTextCondition(parsed) === expression
? parsed
: null;
}

View File

@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
defaultScheduleTrigger,
scheduleFormFromTrigger,
scheduleTriggerFromForm,
scheduleWeekdaysFromCronField,
} from "./workflowSchedule.ts";
test("new schedules default to daily at 09:00 UTC", () => {
assert.deepEqual(defaultScheduleTrigger(), {
on: "schedule",
cron: "0 9 * * *",
});
});
test("recognizes the 15m, 30m, and hourly presets", () => {
assert.equal(
scheduleFormFromTrigger({ on: "schedule", interval: "15m" }).frequency,
"every_15_minutes",
);
assert.equal(
scheduleFormFromTrigger({ on: "schedule", interval: "30m" }).frequency,
"every_30_minutes",
);
assert.equal(
scheduleFormFromTrigger({ on: "schedule", interval: "1h" }).frequency,
"hourly",
);
});
test("round-trips daily, weekly, monthly, and custom cron schedules", () => {
for (const cron of [
"30 14 * * *",
"30 14 * * 1-5",
"30 14 * * 1,3,5",
"30 14 * * 4",
"30 14 23 * *",
"0 */2 * * 1,3,5",
]) {
const form = scheduleFormFromTrigger({ on: "schedule", cron });
assert.deepEqual(scheduleTriggerFromForm(form), { on: "schedule", cron });
}
});
test("preserves arbitrary custom cron and legacy interval strings exactly", () => {
const cron = " 0 */2 * * 1,3,5 ";
const cronForm = scheduleFormFromTrigger({ on: "schedule", cron });
assert.equal(cronForm.frequency, "custom_cron");
assert.equal(cronForm.customCron, cron);
assert.deepEqual(scheduleTriggerFromForm(cronForm), {
on: "schedule",
cron,
});
const interval = "2h30m";
const intervalForm = scheduleFormFromTrigger({ on: "schedule", interval });
assert.equal(intervalForm.frequency, "custom_interval");
assert.equal(intervalForm.customInterval, interval);
assert.deepEqual(scheduleTriggerFromForm(intervalForm), {
on: "schedule",
interval,
});
});
test("expands numeric weekday lists and ranges for the weekly picker", () => {
assert.deepEqual(scheduleWeekdaysFromCronField("1-5"), [
"1",
"2",
"3",
"4",
"5",
]);
assert.deepEqual(scheduleWeekdaysFromCronField("1,3,5"), ["1", "3", "5"]);
assert.deepEqual(scheduleWeekdaysFromCronField("MON-FRI"), []);
});
test("switching schedule modes never emits cron and interval together", () => {
const form = scheduleFormFromTrigger({ on: "schedule", interval: "30m" });
const custom = scheduleTriggerFromForm({
...form,
customCron: "0 8 * * 6",
frequency: "custom_cron",
});
assert.deepEqual(custom, { on: "schedule", cron: "0 8 * * 6" });
assert.equal("interval" in custom, false);
});

View File

@@ -0,0 +1,181 @@
import type { TriggerConfig } from "./workflowFormTypes";
export const SCHEDULE_FREQUENCIES = [
"every_15_minutes",
"every_30_minutes",
"hourly",
"daily",
"weekly",
"monthly",
"custom_cron",
] as const;
export type ScheduleFrequency = (typeof SCHEDULE_FREQUENCIES)[number];
type ScheduleFormFrequency = ScheduleFrequency | "custom_interval";
export type ScheduleFormState = {
customCron: string;
customInterval: string;
frequency: ScheduleFormFrequency;
monthDay: string;
time: string;
weekday: string;
};
export const SCHEDULE_FREQUENCY_LABELS: Record<ScheduleFrequency, string> = {
every_15_minutes: "Every 15 minutes",
every_30_minutes: "Every 30 minutes",
hourly: "Every hour",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
custom_cron: "Custom cron",
};
const INTERVAL_FREQUENCIES = {
"15m": "every_15_minutes",
"30m": "every_30_minutes",
"1h": "hourly",
} as const;
const FREQUENCY_INTERVALS: Partial<Record<ScheduleFrequency, string>> = {
every_15_minutes: "15m",
every_30_minutes: "30m",
hourly: "1h",
};
const DEFAULT_TIME = "09:00";
const DEFAULT_WEEKDAY = "1";
const DEFAULT_MONTH_DAY = "1";
type ParsedCommonCron = {
frequency: "daily" | "weekly" | "monthly";
monthDay?: string;
time: string;
weekday?: string;
};
export function scheduleWeekdaysFromCronField(field: string): string[] {
const weekdays = new Set<number>();
for (const segment of field.split(",")) {
if (/^[0-6]$/.test(segment)) {
weekdays.add(Number(segment));
continue;
}
const range = /^([0-6])-([0-6])$/.exec(segment);
if (!range || Number(range[1]) > Number(range[2])) return [];
for (let day = Number(range[1]); day <= Number(range[2]); day += 1) {
weekdays.add(day);
}
}
return [...weekdays].sort((left, right) => left - right).map(String);
}
function parseCommonCron(cron: string): ParsedCommonCron | null {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return null;
const [minute, hour, monthDay, month, weekday] = fields;
const parsedMinute = Number(minute);
const parsedHour = Number(hour);
if (
!/^\d+$/.test(minute) ||
!/^\d+$/.test(hour) ||
parsedMinute < 0 ||
parsedMinute > 59 ||
parsedHour < 0 ||
parsedHour > 23 ||
month !== "*"
) {
return null;
}
const time = `${String(parsedHour).padStart(2, "0")}:${String(parsedMinute).padStart(2, "0")}`;
if (monthDay === "*" && weekday === "*") {
return { frequency: "daily", time };
}
if (monthDay === "*" && scheduleWeekdaysFromCronField(weekday).length > 0) {
return { frequency: "weekly", time, weekday };
}
if (weekday === "*" && /^(?:[1-9]|[12]\d|3[01])$/.test(monthDay)) {
return { frequency: "monthly", monthDay, time };
}
return null;
}
export function scheduleFormFromTrigger(
trigger: TriggerConfig,
): ScheduleFormState {
const interval = trigger.interval;
if (interval) {
const normalizedInterval = interval.trim();
const frequency =
INTERVAL_FREQUENCIES[
normalizedInterval as keyof typeof INTERVAL_FREQUENCIES
];
return {
customCron: "",
customInterval: frequency ? "" : interval,
frequency: frequency ?? "custom_interval",
monthDay: DEFAULT_MONTH_DAY,
time: DEFAULT_TIME,
weekday: DEFAULT_WEEKDAY,
};
}
const cron = trigger.cron ?? "";
const commonCron = cron ? parseCommonCron(cron) : null;
return {
customCron: commonCron ? "" : cron,
customInterval: "",
frequency: commonCron?.frequency ?? (cron ? "custom_cron" : "daily"),
monthDay: commonCron?.monthDay ?? DEFAULT_MONTH_DAY,
time: commonCron?.time ?? DEFAULT_TIME,
weekday: commonCron?.weekday ?? DEFAULT_WEEKDAY,
};
}
function cronTrigger(cron: string): TriggerConfig {
return { on: "schedule", cron };
}
function intervalTrigger(interval: string): TriggerConfig {
return { on: "schedule", interval };
}
function cronTime(time: string): { hour: string; minute: string } {
const match = /^(\d{2}):(\d{2})$/.exec(time);
if (!match) return { hour: "9", minute: "0" };
return {
hour: String(Number(match[1])),
minute: String(Number(match[2])),
};
}
export function scheduleTriggerFromForm(
form: ScheduleFormState,
): TriggerConfig {
const interval = FREQUENCY_INTERVALS[form.frequency as ScheduleFrequency];
if (interval) return intervalTrigger(interval);
if (form.frequency === "custom_interval") {
return intervalTrigger(form.customInterval);
}
if (form.frequency === "custom_cron") {
return cronTrigger(form.customCron);
}
const { hour, minute } = cronTime(form.time);
switch (form.frequency) {
case "weekly":
return cronTrigger(`${minute} ${hour} * * ${form.weekday}`);
case "monthly":
return cronTrigger(`${minute} ${hour} ${form.monthDay} * *`);
default:
return cronTrigger(`${minute} ${hour} * * *`);
}
}
export function defaultScheduleTrigger(): TriggerConfig {
return scheduleTriggerFromForm(scheduleFormFromTrigger({ on: "schedule" }));
}

View File

@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
readWorkflowDocumentFields,
readWorkflowHeaderState,
yamlWithWorkflowEnabled,
yamlWithWorkflowName,
} from "./workflowYamlDocument.ts";
/** The name the dialog header would render for `yaml`. */
function headerName(yaml, fallbackName) {
return readWorkflowHeaderState(yaml, { enabled: true, name: fallbackName })
.name;
}
/** The enabled state the dialog header would render for `yaml`. */
function headerEnabled(yaml, fallbackEnabled) {
return readWorkflowHeaderState(yaml, {
enabled: fallbackEnabled,
name: undefined,
}).enabled;
}
// A step that has just been added from the builder carries no message text yet,
// so the definition fails full form validation while the user is still on the
// step pane. The header must keep working against that document.
const INCOMPLETE_STEP_YAML = `name: mock-horse-battery
trigger:
on: message_posted
steps:
- id: step_1
action: send_message
`;
test("reads the name of a definition whose steps are still incomplete", () => {
assert.deepEqual(readWorkflowDocumentFields(INCOMPLETE_STEP_YAML), {
editable: true,
enabled: null,
name: "mock-horse-battery",
});
assert.equal(
headerName(INCOMPLETE_STEP_YAML, undefined),
"mock-horse-battery",
);
});
test("treats an empty definition as an editable blank name", () => {
assert.deepEqual(readWorkflowDocumentFields(""), {
editable: true,
enabled: null,
name: null,
});
assert.deepEqual(readWorkflowDocumentFields(" \n"), {
editable: true,
enabled: null,
name: null,
});
assert.equal(headerName("", undefined), "");
});
test("falls back to the saved name only when the document has none", () => {
assert.equal(headerName("", "Saved name"), "Saved name");
assert.equal(
headerName("name: ''\ntrigger:\n on: webhook\n", "Saved name"),
"Saved name",
);
assert.equal(
headerName(INCOMPLETE_STEP_YAML, "Saved name"),
"mock-horse-battery",
);
});
test("trims the rendered name and the saved fallback", () => {
assert.equal(headerName('name: " spaced "\n', undefined), "spaced");
assert.equal(headerName("", " saved "), "saved");
});
test("marks unparseable or non-map documents as uneditable", () => {
assert.deepEqual(readWorkflowDocumentFields("name: [unclosed\n"), {
editable: false,
enabled: null,
name: null,
});
assert.deepEqual(readWorkflowDocumentFields("- just\n- a list\n"), {
editable: false,
enabled: null,
name: null,
});
assert.equal(yamlWithWorkflowName("- just\n- a list\n", "next"), null);
assert.equal(yamlWithWorkflowEnabled("- just\n- a list\n", false), null);
});
test("ignores a non-string name rather than rendering it", () => {
assert.equal(readWorkflowDocumentFields("name: 42\n").name, null);
assert.equal(headerName("name: 42\n", "Saved name"), "Saved name");
});
test("keeps the header editable while a step is still incomplete", () => {
assert.deepEqual(
readWorkflowHeaderState(INCOMPLETE_STEP_YAML, {
enabled: true,
name: "Saved name",
}),
{ canEdit: true, enabled: true, name: "mock-horse-battery" },
);
assert.equal(
readWorkflowHeaderState("name: [unclosed\n", {
enabled: false,
name: "Saved name",
}).canEdit,
false,
);
});
test("derives enabled from the document, defaulting to enabled", () => {
assert.equal(headerEnabled(INCOMPLETE_STEP_YAML, false), true);
assert.equal(
headerEnabled(`enabled: false\n${INCOMPLETE_STEP_YAML}`, true),
false,
);
// Only an unreadable document may fall back to the saved value.
assert.equal(headerEnabled("- just\n- a list\n", false), false);
assert.equal(headerEnabled("- just\n- a list\n", true), true);
});
test("writes the name back without disturbing the rest of the document", () => {
const next = yamlWithWorkflowName(INCOMPLETE_STEP_YAML, "renamed");
assert.equal(headerName(next, undefined), "renamed");
assert.match(next, /action: send_message/);
assert.match(next, /on: message_posted/);
});
test("seeds a definition when naming an empty document", () => {
const next = yamlWithWorkflowName("", "fresh");
assert.equal(headerName(next, undefined), "fresh");
assert.match(next, /trigger:/);
});
test("adds and removes the enabled key without touching the name", () => {
const disabled = yamlWithWorkflowEnabled(INCOMPLETE_STEP_YAML, false);
assert.match(disabled, /enabled: false/);
assert.equal(headerName(disabled, undefined), "mock-horse-battery");
const reEnabled = yamlWithWorkflowEnabled(disabled, true);
assert.doesNotMatch(reEnabled, /enabled:/);
assert.equal(headerName(reEnabled, undefined), "mock-horse-battery");
});

View File

@@ -0,0 +1,114 @@
import { isMap, parseDocument } from "yaml";
import { DEFAULT_FORM_STATE, formStateToYaml } from "./workflowFormTypes";
/**
* Header-level view of a workflow definition.
*
* The dialog header (name, name editing, enabled toggle) must stay usable while
* the body of the definition is still incomplete — a freshly added step has no
* message text yet, so full form validation fails even though `name` is present
* and editable. These fields therefore come from the YAML document itself
* rather than from {@link yamlToFormState}.
*/
export type WorkflowDocumentFields = {
/** Whether top-level keys can be written back into the document. */
editable: boolean;
/** The explicit `enabled` value, or `null` when the key is absent. */
enabled: boolean | null;
/** The top-level `name` scalar, or `null` when absent or not a string. */
name: string | null;
};
const EMPTY_DOCUMENT_FIELDS: WorkflowDocumentFields = {
editable: true,
enabled: null,
name: null,
};
const UNEDITABLE_DOCUMENT_FIELDS: WorkflowDocumentFields = {
editable: false,
enabled: null,
name: null,
};
/**
* Reads the header-level fields of a workflow definition without requiring the
* whole definition to be valid.
*/
export function readWorkflowDocumentFields(
yaml: string,
): WorkflowDocumentFields {
if (!yaml.trim()) return EMPTY_DOCUMENT_FIELDS;
const document = parseDocument(yaml);
if (document.errors.length > 0 || !isMap(document.contents)) {
return UNEDITABLE_DOCUMENT_FIELDS;
}
const name = document.get("name");
const enabled = document.get("enabled");
return {
editable: true,
enabled: typeof enabled === "boolean" ? enabled : null,
name: typeof name === "string" ? name : null,
};
}
/** What the dialog header renders for a definition, in a single parse. */
export type WorkflowHeaderState = {
/** Whether the name can be edited and the enabled toggle can be written. */
canEdit: boolean;
enabled: boolean;
name: string;
};
/**
* Derives the header presentation from the working definition, falling back to
* the saved workflow only for what the document itself does not supply.
*/
export function readWorkflowHeaderState(
yaml: string,
fallback: { enabled: boolean; name: string | undefined },
): WorkflowHeaderState {
const fields = readWorkflowDocumentFields(yaml);
return {
canEdit: fields.editable,
enabled: fields.editable ? fields.enabled !== false : fallback.enabled,
name: fields.name?.trim() || (fallback.name?.trim() ?? ""),
};
}
/** Writes `name` into the definition, preserving everything else verbatim. */
export function yamlWithWorkflowName(
yaml: string,
name: string,
): string | null {
if (!yaml.trim()) {
return formStateToYaml({ ...DEFAULT_FORM_STATE, name });
}
const document = parseDocument(yaml);
if (document.errors.length > 0 || !isMap(document.contents)) return null;
document.set("name", name);
return document.toString();
}
/** Writes `enabled` into the definition, preserving everything else verbatim. */
export function yamlWithWorkflowEnabled(
yaml: string,
enabled: boolean,
): string | null {
if (!yaml.trim()) {
return formStateToYaml({ ...DEFAULT_FORM_STATE, enabled });
}
const document = parseDocument(yaml);
if (document.errors.length > 0 || !isMap(document.contents)) return null;
if (enabled) {
document.delete("enabled");
} else {
document.set("enabled", false);
}
return document.toString();
}

View File

@@ -0,0 +1,50 @@
import * as React from "react";
import type { Workflow } from "@/shared/api/types";
type WorkflowEditorOverlayContextValue = {
/** Opens the create editor over the current surface, optionally preselecting
* a channel. */
openNewWorkflow: ((channelId?: string) => void) | null;
/** Opens an existing workflow over the current surface. Pass the workflow
* when the caller already has it so the editor skips its loading state. */
openWorkflow: ((workflowId: string, workflow?: Workflow) => void) | null;
};
const WorkflowEditorOverlayContext =
React.createContext<WorkflowEditorOverlayContextValue>({
openNewWorkflow: null,
openWorkflow: null,
});
/**
* Lets any surface open the shared workflow editor as a modal above itself,
* instead of navigating to the Workflows route first.
*/
export function WorkflowEditorOverlayProvider({
children,
onOpenNewWorkflow,
onOpenWorkflow,
}: {
children: React.ReactNode;
onOpenNewWorkflow: (channelId?: string) => void;
onOpenWorkflow: (workflowId: string, workflow?: Workflow) => void;
}) {
const value = React.useMemo(
() => ({
openNewWorkflow: onOpenNewWorkflow,
openWorkflow: onOpenWorkflow,
}),
[onOpenNewWorkflow, onOpenWorkflow],
);
return (
<WorkflowEditorOverlayContext.Provider value={value}>
{children}
</WorkflowEditorOverlayContext.Provider>
);
}
export function useWorkflowEditorOverlay() {
return React.useContext(WorkflowEditorOverlayContext);
}

View File

@@ -0,0 +1,39 @@
import type * as React from "react";
type PortalledScrollAreaProps = React.ComponentPropsWithoutRef<"div">;
/**
* Keeps a portalled overflow container wheel-scrollable when it is rendered
* outside a modal's scroll-lock boundary.
*/
export function PortalledScrollArea({
onWheel,
...props
}: PortalledScrollAreaProps) {
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
onWheel?.(event);
if (event.defaultPrevented) return;
const area = event.currentTarget;
const maxScrollTop = area.scrollHeight - area.clientHeight;
if (maxScrollTop <= 0) return;
const multiplier =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? area.clientHeight
: 1;
const nextScrollTop = Math.max(
0,
Math.min(maxScrollTop, area.scrollTop + event.deltaY * multiplier),
);
if (nextScrollTop === area.scrollTop) return;
area.scrollTop = nextScrollTop;
event.preventDefault();
event.stopPropagation();
}
return <div {...props} onWheel={handleWheel} />;
}

View File

@@ -27,6 +27,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor;
type PopoverContentProps = React.ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content
> & {
portalled?: boolean;
surface?: "default" | "textured";
textureSize?: CardTextureSize;
textureTone?: CardTextureTone;
@@ -40,6 +41,7 @@ const PopoverContent = React.forwardRef<
{
className,
align = "center",
portalled = true,
sideOffset,
style,
surface = "default",
@@ -48,8 +50,8 @@ const PopoverContent = React.forwardRef<
...props
},
ref,
) => (
<PopoverPrimitive.Portal>
) => {
const content = (
<PopoverPrimitive.Content
ref={ref}
align={align}
@@ -72,8 +74,14 @@ const PopoverContent = React.forwardRef<
}}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
return portalled ? (
<PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
) : (
content
);
},
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

View File

@@ -250,6 +250,10 @@ type E2eConfig = {
acpAuthMethodsError?: string;
/** When set, workflow updates fail with this message. */
workflowUpdateError?: string;
/** When set, workflow deletion fails with this message. */
workflowDeleteError?: string;
/** Reject workflow run creation with this message. */
workflowTriggerError?: string;
/** When set, the `delete_custom_harness` mock command throws with this message. */
deleteCustomHarnessError?: string;
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
@@ -3754,6 +3758,8 @@ function handleUpdateWorkflow(args: {
}
function handleDeleteWorkflow(args: { workflowId: string }) {
const configuredError = window.__BUZZ_E2E__?.mock?.workflowDeleteError;
if (configuredError) throw new Error(configuredError);
const index = mockWorkflows.findIndex((w) => w.id === args.workflowId);
if (index === -1) throw new Error(`Workflow ${args.workflowId} not found`);
mockWorkflows.splice(index, 1);
@@ -3826,6 +3832,8 @@ function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun {
function handleTriggerWorkflow(args: { workflowId: string }) {
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
const configuredError = window.__BUZZ_E2E__?.mock?.workflowTriggerError;
if (configuredError) throw new Error(configuredError);
const run = buildMockWorkflowRun(workflow);
mockWorkflowRuns = [run, ...mockWorkflowRuns];
return {

View File

@@ -5,6 +5,7 @@ import {
KIND_HUDDLE_STARTED,
KIND_TYPING_INDICATOR,
} from "../../src/shared/constants/kinds";
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
import {
TEST_IDENTITIES,
installMockBridge,
@@ -2952,6 +2953,140 @@ test("manage channel places canvas between channel info and actions", async ({
expect(canvasBox?.y).toBeLessThan(leaveBox?.y);
});
test("channel settings hides workflows and skips its query when the experiment is disabled", async ({
page,
}) => {
await page.addInitScript((key) => {
const overrides = JSON.parse(
window.localStorage.getItem(key) ?? "{}",
) as Record<string, boolean>;
overrides.workflows = false;
window.localStorage.setItem(key, JSON.stringify(overrides));
}, FEATURE_OVERRIDES_STORAGE_KEY);
await page.goto("/");
await openChannelManagement(page, "general");
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-workflows-ingress")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_LOG__?.some(
({ command }) => command === "get_channel_workflows",
),
),
)
.toBe(false);
});
test("channel settings opens and creates channel workflows over the channel", async ({
page,
}) => {
await page.goto("/");
await invokeMockCommand(page, "create_workflow", {
channelId: GENERAL_CHANNEL_ID,
yamlDefinition:
"name: Welcome responder\ntrigger:\n on: message_posted\nsteps:\n - id: reply\n run: send_message\n with:\n text: Welcome\n",
});
await openChannelManagement(page, "general");
const sheet = page.getByTestId("channel-management-sheet");
const canvasBox = await sheet
.getByTestId("channel-canvas-ingress")
.boundingBox();
const workflowsBox = await sheet
.getByTestId("channel-workflows-ingress")
.boundingBox();
expect(canvasBox).not.toBeNull();
expect(workflowsBox).not.toBeNull();
expect(canvasBox?.y).toBeLessThan(workflowsBox?.y);
await sheet.getByTestId("channel-workflows-ingress").click();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(sheet.getByTestId("channel-workflows-list")).toContainText(
"Welcome responder",
);
// Opening a workflow keeps the settings Workflows view mounted beneath the
// shared editor; the channel route stays put behind both layers.
const channelUrl = new RegExp(`/channels/${GENERAL_CHANNEL_ID}(?:\\?|$)`);
await sheet.getByTestId("channel-workflow-mock-wf-1").click();
await expect(
page.getByRole("dialog", { name: "Edit workflow" }),
).toBeVisible();
await expect(page).toHaveURL(channelUrl);
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(page.getByTestId("message-timeline")).toBeVisible();
const overlayEditor = page.getByRole("dialog", { name: "Edit workflow" });
await overlayEditor.getByRole("tab", { name: "YAML" }).click();
const overlayYaml = overlayEditor.getByRole("textbox", {
name: "Workflow YAML",
});
await overlayYaml.fill(
(await overlayYaml.inputValue()).replace(
"Welcome responder",
"Unsaved welcome responder",
),
);
await overlayEditor.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Duplicate" }).click();
const discardConfirmation = page.getByRole("alertdialog", {
name: "Discard changes?",
});
await expect(discardConfirmation).toBeVisible();
await discardConfirmation
.getByRole("button", { name: "Keep editing" })
.click();
await expect(overlayEditor).toBeVisible();
await expect(overlayYaml).toContainText("Unsaved welcome responder");
await expect(
page.getByRole("dialog", { name: "Duplicate workflow" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Close" }).click();
await page
.getByRole("alertdialog", { name: "Discard changes?" })
.getByRole("button", { name: "Discard changes" })
.click();
await expect(page.getByRole("dialog", { name: "Edit workflow" })).toHaveCount(
0,
);
await expect(page).toHaveURL(channelUrl);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(sheet.getByTestId("channel-workflows-list")).toContainText(
"Welcome responder",
);
await page.getByTestId("channel-workflows-new").click();
await expect(
page.getByRole("dialog", { name: "Create workflow" }),
).toBeVisible();
await expect(page).toHaveURL(channelUrl);
await expect(
page.getByRole("combobox", { exact: true, name: "Channel" }),
).toContainText("general");
await page.getByRole("button", { name: "Cancel" }).click();
await expect(
page.getByRole("dialog", { name: "Create workflow" }),
).toHaveCount(0);
await expect(page).toHaveURL(channelUrl);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
await expect(sheet.getByTestId("channel-workflows-new")).toBeVisible();
});
async function seedHomeInboxMention(
page: import("@playwright/test").Page,
itemId: string,

View File

@@ -24,10 +24,22 @@ async function createWorkflow(
name: string,
) {
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await expect(dialog).toBeVisible();
await dialog.getByLabel("Workflow name").fill(name);
await dialog.getByRole("button", { name: "Add step" }).click();
const channelList = page.getByTestId("channel-combobox-list");
await expect(channelList).toBeVisible();
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog.getByRole("button", { name: "Create" }).click();
await expect(dialog).not.toBeVisible();
}
@@ -124,8 +136,15 @@ test("direct workflow detail links close back to workflows", async ({
await page.goto(`/#/workflows/${workflowId}`);
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
await page.getByRole("button", { name: "Close detail panel" }).click();
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog.getByText(workflowName, { exact: true })).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Trigger: Message Posted" }),
).toBeVisible();
await expect(dialog.getByRole("button", { name: "Run history" })).toHaveCount(
0,
);
await dialog.getByRole("button", { name: "Close", exact: true }).click();
await expect(page).toHaveURL(/#\/workflows$/);
await expect(page.getByTestId("workflows-view")).toBeVisible();

View File

@@ -0,0 +1,242 @@
import { expect, test } from "@playwright/test";
import { parse as parseYaml } from "yaml";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function openCreateWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const channelList = page.getByTestId("channel-combobox-list");
if (!(await channelList.isVisible())) {
await dialog.getByRole("combobox", { name: "Channel" }).click();
}
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
return dialog;
}
async function selectTrigger(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
trigger: string,
) {
await dialog.getByRole("button", { name: "Trigger event" }).click();
await page.getByRole("menuitem", { name: trigger, exact: true }).click();
}
async function openTriggerInspector(
dialog: import("@playwright/test").Locator,
trigger: string,
) {
const menu = dialog.getByRole("button", { name: "Trigger event" });
if (!(await menu.isVisible())) {
await dialog.getByRole("button", { name: `Trigger: ${trigger}` }).click();
}
await expect(menu).toBeVisible();
}
async function addMessageStep(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
) {
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
}
async function reopenWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name });
await card.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
return page.getByRole("dialog", { name: "Edit workflow" });
}
test("round-trips schedule presets and saves a custom UTC cron", async ({
page,
}) => {
const name = `schedule_controls_${Date.now()}`;
const dialog = await openCreateWorkflow(page, name);
await selectTrigger(page, dialog, "Schedule");
await expect(dialog.getByRole("radio", { name: "Daily" })).toBeChecked();
await expect(dialog.getByLabel("Run time (UTC)")).toHaveValue("09:00");
await dialog.getByText("Every 15 minutes", { exact: true }).click();
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
let definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({ on: "schedule", interval: "15m" });
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await expect(
dialog.getByRole("radio", { name: "Every 15 minutes" }),
).toBeChecked();
await dialog.getByText("Monthly", { exact: true }).click();
await dialog.getByLabel("Day of month").selectOption("31");
await expect(
dialog.getByText("This schedule won’t run in some months."),
).toBeVisible();
await dialog.getByText("Custom cron", { exact: true }).click();
await dialog.getByRole("textbox", { name: "Minute", exact: true }).fill("5");
await dialog.getByRole("textbox", { name: "Hour", exact: true }).fill("*/2");
await dialog.getByRole("textbox", { name: "Day", exact: true }).fill("*");
await dialog.getByRole("textbox", { name: "Month", exact: true }).fill("*");
await dialog
.getByRole("textbox", { name: "Weekday", exact: true })
.fill("2-4");
await expect(dialog.getByText(/UTC · Paste all 5 fields/)).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({
on: "schedule",
cron: "5 */2 * * 2-4",
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await expect(
dialog.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
await expect(
dialog.getByRole("textbox", { name: "Weekday", exact: true }),
).toHaveValue("2-4");
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened, "Schedule");
await expect(
reopened.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
await expect(
reopened.getByRole("textbox", { name: "Minute", exact: true }),
).toHaveValue("5");
await expect(
reopened.getByRole("textbox", { name: "Weekday", exact: true }),
).toHaveValue("2-4");
});
test("round-trips and reopens structured message-text conditions", async ({
page,
}) => {
const name = `message_condition_${Date.now()}`;
const text = 'deploy "buzz"\\path';
const expression = 'str_ends_with(trigger_text, "deploy \\"buzz\\"\\\\path")';
const dialog = await openCreateWorkflow(page, name);
await dialog.getByText("ends with", { exact: true }).click();
await dialog.getByLabel("Text to match").fill(text);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger.filter).toBe(expression);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Message Posted");
await expect(dialog.getByText("ends with", { exact: true })).toBeVisible();
await expect(dialog.getByLabel("Text to match")).toHaveValue(text);
await dialog.getByRole("tab", { name: "Advanced" }).click();
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(
expression,
);
await dialog.getByRole("tab", { name: "Basic" }).click();
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened, "Message Posted");
await expect(reopened.getByText("ends with", { exact: true })).toBeVisible();
await expect(reopened.getByLabel("Text to match")).toHaveValue(text);
});
test("hides and clears message-text step conditions for schedule triggers", async ({
page,
}) => {
const dialog = await openCreateWorkflow(
page,
`schedule_step_condition_${Date.now()}`,
);
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Run controls" }).click();
await dialog.getByLabel("Text to match").fill("deploy");
await dialog.getByRole("button", { name: "Trigger: Message Posted" }).click();
await selectTrigger(page, dialog, "Schedule");
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
await dialog.getByRole("button", { name: "Run controls" }).click();
await expect(dialog.getByLabel("Text to match")).toHaveCount(0);
await expect(
dialog.getByRole("textbox", { name: "Timeout", exact: true }),
).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
const definition = parseYaml(
await dialog.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
);
expect(definition.steps[0].if).toBeUndefined();
});
test("keeps advanced and malformed definitions lossless", async ({ page }) => {
const advanced =
'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
const dialog = await openCreateWorkflow(page, `lossless_${Date.now()}`);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const initialYaml = await yamlEditor.inputValue();
await yamlEditor.fill(
initialYaml.replace(
"trigger:\n on: message_posted",
`trigger:\n on: message_posted\n filter: '${advanced}'`,
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Message Posted");
await expect(dialog.getByRole("tab", { name: "Advanced" })).toHaveAttribute(
"data-state",
"active",
);
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(advanced);
await dialog.getByRole("tab", { name: "Basic" }).click();
await expect(
dialog.getByText(/advanced expression is active/i),
).toBeVisible();
await dialog.getByRole("switch", { name: "Enable workflow" }).click();
await dialog.getByRole("tab", { name: "YAML" }).click();
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
advanced,
);
const malformedYaml = (await yamlEditor.inputValue()).replace(
/trigger:\n {2}on: message_posted\n {2}filter:.*\n/,
'trigger:\n on: schedule\n cron: "0 9 * * *"\n interval: 1h\n',
);
await yamlEditor.fill(malformedYaml);
await dialog.getByRole("tab", { name: "Form" }).click();
await expect(
dialog.getByText(/cannot specify both cron and interval/i),
).toBeVisible();
await expect(yamlEditor).toHaveValue(malformedYaml);
});

View File

@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
// The Reaction Added trigger stores the reaction event's content verbatim — a
// native glyph or a custom `:shortcode:`. These cover the picker interaction and
// that the chosen value survives into canonical YAML and back out of a reload,
// which a free-text field silently got wrong (a typed `thumbsup` never matches).
const EMOJI = "🚀";
const EMOJI_SHORTCODE = ":rocket:";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function openReactionTrigger(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page.getByTestId("workflows-view")).toBeVisible();
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await expect(dialog).toBeVisible();
const channelList = page.getByTestId("channel-combobox-list");
if (!(await channelList.isVisible())) {
await dialog.getByRole("combobox", { name: "Channel" }).click();
}
await expect(channelList).toBeVisible();
await waitForAnimations(page);
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
await dialog.getByRole("button", { name: "Trigger: Message Posted" }).click();
await dialog.getByRole("button", { name: "Trigger event" }).click();
await page.getByRole("menuitem", { name: "Reaction Added" }).click();
return dialog;
}
async function pickEmoji(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
query: string,
glyph: string,
) {
await dialog.getByRole("button", { name: "Choose trigger emoji" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill(query);
await picker.locator(`button[aria-label='${glyph}']`).first().click();
}
test("reaction trigger uses the emoji picker instead of a free-text field", async ({
page,
}) => {
const dialog = await openReactionTrigger(page);
// The old plain text input is gone; the picker trigger stands in its place.
const pickerButton = dialog.getByRole("button", {
name: "Choose trigger emoji",
});
await expect(pickerButton).toBeVisible();
await expect(pickerButton).toHaveText(/Choose a reaction/);
await expect(
dialog.getByRole("textbox", { name: "Emoji filter (optional)" }),
).toHaveCount(0);
// Nothing to clear while the optional filter is empty.
await expect(
dialog.getByRole("button", { name: "Clear trigger emoji" }),
).toHaveCount(0);
await pickEmoji(page, dialog, "rocket", EMOJI);
// Selecting closes the popover and shows the chosen reaction by name.
await expect(page.locator("em-emoji-picker")).toHaveCount(0);
await expect(pickerButton).toContainText(EMOJI_SHORTCODE);
await expect(pickerButton).not.toContainText("Choose a reaction");
});
test("picked reaction persists into canonical YAML and clears back out", async ({
page,
}) => {
const dialog = await openReactionTrigger(page);
await pickEmoji(page, dialog, "rocket", EMOJI);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const yaml = await yamlEditor.inputValue();
expect(yaml).toContain("on: reaction_added");
// Stored verbatim as the reaction content the executor compares against.
expect(yaml).toContain(`emoji: ${EMOJI}`);
// Clearing the optional filter removes the key entirely rather than
// writing an empty string, so the trigger matches any reaction again.
await dialog.getByRole("tab", { name: "Form" }).click();
// Switching editor modes drops the inspector selection, so re-open the
// trigger node before reaching for its fields.
await dialog.getByRole("button", { name: "Trigger: Reaction Added" }).click();
await dialog.getByRole("button", { name: "Clear trigger emoji" }).click();
await expect(
dialog.getByRole("button", { name: "Choose trigger emoji" }),
).toHaveText(/Choose a reaction/);
await dialog.getByRole("tab", { name: "YAML" }).click();
const clearedYaml = await yamlEditor.inputValue();
expect(clearedYaml).toContain("on: reaction_added");
expect(clearedYaml).not.toContain("emoji:");
});
test("legacy free-text emoji values survive a trip through the picker", async ({
page,
}) => {
// The field used to be free text, so existing definitions can hold a bare
// shortcode. Swapping in a picker must not silently drop a value it cannot
// render as a glyph — the user has to be able to see and re-pick it.
const dialog = await openReactionTrigger(page);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const seeded = (await yamlEditor.inputValue()).replace(
"trigger:\n on: reaction_added",
"trigger:\n on: reaction_added\n emoji: thumbsup",
);
await yamlEditor.fill(seeded);
await dialog.getByRole("tab", { name: "Form" }).click();
await dialog.getByRole("button", { name: "Trigger: Reaction Added" }).click();
const pickerButton = dialog.getByRole("button", {
name: "Choose trigger emoji",
});
await expect(pickerButton).toContainText("thumbsup");
await expect(pickerButton).not.toContainText("Choose a reaction");
await dialog.getByRole("tab", { name: "YAML" }).click();
expect(await yamlEditor.inputValue()).toContain("emoji: thumbsup");
});
test("saved reaction filter round-trips back into the picker", async ({
page,
}) => {
const name = `reaction_picker_${Date.now()}`;
const dialog = await openReactionTrigger(page);
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await pickEmoji(page, dialog, "rocket", EMOJI);
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const editDialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(editDialog).toBeVisible();
await editDialog
.getByRole("button", { name: "Trigger: Reaction Added" })
.click();
// Reopening rehydrates the stored glyph into the picker, not an empty field.
await expect(
editDialog.getByRole("button", { name: "Choose trigger emoji" }),
).toContainText(EMOJI_SHORTCODE);
});
test("emoji picker opens within the viewport at narrow width", async ({
page,
}) => {
// Reach the trigger inspector at desktop width — below 58rem the inspector
// becomes an absolute overlay covering the canvas nodes — then narrow the
// window with the inspector already open and drive the picker there.
const dialog = await openReactionTrigger(page);
await page.setViewportSize({ width: 800, height: 720 });
await waitForAnimations(page);
await dialog.getByRole("button", { name: "Choose trigger emoji" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await waitForAnimations(page);
// Radix collision handling must keep the popover on-screen, otherwise the
// picker is unreachable at this width.
const box = await picker.boundingBox();
expect(box).not.toBeNull();
if (box) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(800);
}
await picker.locator("input[type='search']").fill("rocket");
await picker.locator(`button[aria-label='${EMOJI}']`).first().click();
await expect(
dialog.getByRole("button", { name: "Choose trigger emoji" }),
).toContainText(EMOJI_SHORTCODE);
});

View File

@@ -0,0 +1,275 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
const GENERATED_NAME = /^[a-z]+-[a-z]+-[a-z]+$/;
async function navigateToWorkflows(page: Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page.getByTestId("workflows-view")).toBeVisible();
}
function readWorkflowName(dialog: Locator) {
return dialog
.getByRole("button", { name: "Edit workflow name" })
.evaluate((button) =>
(button.previousElementSibling?.textContent ?? "").trim(),
);
}
async function selectWorkflowChannel(page: Page, dialog: Locator) {
const channelList = page.getByTestId("channel-combobox-list");
if (!(await channelList.isVisible())) {
await dialog.getByRole("combobox", { name: "Channel" }).click();
}
await expect(channelList).toBeVisible();
await waitForAnimations(page);
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
}
async function openTriggerPane(dialog: Locator) {
await dialog.getByRole("button", { name: /^Trigger: / }).click();
await expect(
dialog.getByRole("button", { name: "Trigger event" }),
).toBeVisible();
}
async function openStepPane(dialog: Locator, index: number) {
await dialog
.getByRole("button", { name: new RegExp(`^Step ${index}: `) })
.click();
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
`Step ${index}`,
);
}
/**
* Inserts a step right after the trigger. The insertion control only becomes
* interactive on hover once the trigger is no longer the terminal node.
*/
async function addStepAfterTrigger(page: Page, dialog: Locator) {
const ingress = dialog.getByTestId("workflow-node-ingress").first();
await ingress.hover();
await ingress.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
}
async function createNamedWorkflow(page: Page, name: string) {
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await expect(dialog).toBeVisible();
await selectWorkflowChannel(page, dialog);
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await dialog.getByRole("button", { name: "Add first step" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog.getByRole("button", { name: "Create workflow" }).click();
await expect(page.getByTestId("workflows-view")).toContainText(name);
}
function workflowCard(page: Page, name: string) {
return page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
}
test("keeps the generated name while moving between trigger and step panes", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await expect.poll(() => readWorkflowName(dialog)).toMatch(GENERATED_NAME);
const generatedName = await readWorkflowName(dialog);
// Adding the first step lands on the step pane while the step is still
// incomplete — the header must keep showing the workflow name.
await dialog.getByRole("button", { name: "Add first step" }).click();
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(generatedName);
await expect(
dialog.getByRole("button", { name: "Edit workflow name" }),
).toBeEnabled();
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(generatedName);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(generatedName);
// A second, still-empty step must not clear the name either.
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(generatedName);
await openStepPane(dialog, 2);
expect(await readWorkflowName(dialog)).toBe(generatedName);
});
test("renames stay visible across pane navigation before the step is filled in", async ({
page,
}) => {
const name = `renamed_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await dialog.getByRole("button", { name: "Add first step" }).click();
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
expect(await readWorkflowName(dialog)).toBe(name);
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(name);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(name);
// Completing the step re-serializes the definition from the builder's form
// state, which must have picked the rename up rather than writing back the
// generated name it was holding when the rename happened.
await dialog.getByLabel("Message text").fill("Workflow notification");
expect(await readWorkflowName(dialog)).toBe(name);
await dialog.getByRole("button", { name: "Create workflow" }).click();
await expect(page.getByTestId("workflows-view")).toContainText(name);
});
test("keeps a header disable across a later form edit", async ({ page }) => {
const name = `disabled_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await createNamedWorkflow(page, name);
await workflowCard(page, name)
.getByRole("button", { name: "Workflow actions" })
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
// The open menu aria-hides the dialog behind it, so it has to be dismissed
// before the dialog can be addressed again.
const closeActionsMenu = async () => {
await page.keyboard.press("Escape");
await expect(enableItem).not.toBeVisible();
};
// Add an empty step so the definition stops validating, then disable from the
// header. Completing the step re-serializes the definition from the builder's
// form state, which must carry the disable rather than the enabled state it
// held before the header wrote it.
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
await dialog.getByRole("button", { name: "Workflow actions" }).click();
await expect(enableItem).toHaveAttribute("aria-checked", "true");
await enableItem.click();
await expect(enableItem).toHaveAttribute("aria-checked", "false");
await closeActionsMenu();
await dialog.getByLabel("Message text").fill("Updated notification");
await dialog.getByRole("button", { name: "Workflow actions" }).click();
await expect(enableItem).toHaveAttribute("aria-checked", "false");
await closeActionsMenu();
await dialog.getByRole("button", { name: "Save changes" }).click();
await expect(workflowCard(page, name)).toContainText("disabled");
});
test("keeps the saved name while editing an existing workflow", async ({
page,
}) => {
const name = `edit_title_${Date.now()}`;
await navigateToWorkflows(page);
await createNamedWorkflow(page, name);
await workflowCard(page, name)
.getByRole("button", { name: "Workflow actions" })
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
expect(await readWorkflowName(dialog)).toBe(name);
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(name);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(name);
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(name);
await expect(
dialog.getByRole("button", { name: "Edit workflow name" }),
).toBeEnabled();
await openStepPane(dialog, 2);
expect(await readWorkflowName(dialog)).toBe(name);
});
test("keeps the copy name while duplicating an existing workflow", async ({
page,
}) => {
const name = `dup_title_${Date.now()}`;
await navigateToWorkflows(page);
await createNamedWorkflow(page, name);
await workflowCard(page, name)
.getByRole("button", { name: "Workflow actions" })
.click();
await page.getByRole("menuitem", { name: "Duplicate" }).click();
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
await expect(dialog).toBeVisible();
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
});

View File

@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
@@ -13,6 +14,36 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) {
await expect(page.getByTestId("workflows-view")).toBeVisible();
}
async function selectWorkflowChannel(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
) {
const channelList = page.getByTestId("channel-combobox-list");
if (!(await channelList.isVisible())) {
await dialog.getByRole("combobox", { name: "Channel" }).click();
}
await expect(channelList).toBeVisible();
await waitForAnimations(page);
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
}
async function editWorkflowName(
dialog: import("@playwright/test").Locator,
name: string,
) {
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
const input = dialog.getByRole("textbox", { name: "Workflow name" });
await input.fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await expect(input).not.toBeVisible();
await expect(dialog).toContainText(name);
await expect(
dialog.page().getByTestId("channel-combobox-list"),
).not.toBeVisible();
}
async function createWorkflow(
page: import("@playwright/test").Page,
name: string,
@@ -20,38 +51,67 @@ async function createWorkflow(
description?: string;
enabled?: boolean;
trigger?: string;
stepCondition?: string;
stepConditionText?: string;
stepName?: string;
stepTimeoutSecs?: string;
},
) {
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await expect(dialog).toBeVisible();
await dialog.getByLabel("Workflow name").fill(name);
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, name);
if (options?.description) {
await dialog.getByLabel("Description (optional)").fill(options.description);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const yaml = await yamlEditor.inputValue();
await yamlEditor.fill(
yaml.replace(
`name: ${name}`,
`name: ${name}\ndescription: ${JSON.stringify(options.description)}`,
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
}
if (options?.enabled === false) {
await dialog.getByLabel("Workflow is enabled").click();
await dialog.getByRole("switch", { name: "Enable" }).click();
}
if (options?.trigger) {
await dialog.getByLabel("Trigger").selectOption(options.trigger);
await dialog
.getByRole("button", { name: "Trigger: Message Posted" })
.click();
await dialog.getByRole("button", { name: "Trigger event" }).click();
await page
.getByRole("menuitem", {
name:
options.trigger === "diff_posted"
? "Diff Posted"
: options.trigger === "reaction_added"
? "Reaction Added"
: options.trigger === "webhook"
? "Webhook"
: "Message Posted",
})
.click();
}
await dialog.getByRole("button", { name: "Add step" }).click();
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
if (options?.stepName) {
await dialog.getByLabel("Step name (optional)").fill(options.stepName);
await dialog.getByRole("button", { name: "Step details" }).click();
await dialog.getByLabel("Name (optional)").fill(options.stepName);
}
if (options?.stepCondition) {
await dialog
.getByLabel("Run condition (optional)")
.fill(options.stepCondition);
if (options?.stepConditionText || options?.stepTimeoutSecs) {
await dialog.getByRole("button", { name: "Run controls" }).click();
}
if (options?.stepConditionText) {
await dialog.getByLabel("Text to match").fill(options.stepConditionText);
}
if (options?.stepTimeoutSecs) {
await dialog
.getByLabel("Timeout seconds (optional)")
.getByRole("textbox", { name: "Timeout", exact: true })
.fill(options.stepTimeoutSecs);
}
@@ -71,6 +131,59 @@ test("navigates to workflows view and shows the empty create tile", async ({
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
});
test("creation reveals the trigger pane only after a one-shot channel pick", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const channelList = page.getByTestId("channel-combobox-list");
await expect(channelList).toBeVisible();
await expect(dialog.getByTestId("workflow-node-inspector")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(channelList).not.toBeVisible();
await expect
.poll(() => dialog.getByText("Untitled workflow", { exact: true }).count())
.toBe(0);
await expect(channelList).not.toBeVisible();
await dialog.getByRole("combobox", { name: "Channel" }).click();
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
await expect(dialog.getByTestId("workflow-node-inspector")).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Trigger event" }),
).toBeVisible();
});
test("Escape closes the selected inspector before the workflow modal", async ({
page,
}) => {
for (const width of [760, 1280]) {
await page.setViewportSize({ width, height: 820 });
await page.goto(
"/#/workflows?view=create&channel=94a444a4-c0a3-5966-ab05-530c6ddc2301&pane=trigger",
);
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const inspector = dialog.getByTestId("workflow-node-inspector");
await expect(dialog).toBeVisible();
await expect(inspector).toBeVisible();
await page.keyboard.press("Escape");
await expect(inspector).not.toBeVisible();
await expect(dialog).toBeVisible();
await expect(page).toHaveURL(/view=create/);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/#\/workflows$/);
}
});
test("creates a workflow via the form builder", async ({ page }) => {
const workflowName = `test_workflow_${Date.now()}`;
@@ -81,21 +194,64 @@ test("creates a workflow via the form builder", async ({ page }) => {
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
});
test("accepts the generated name through the first form mutation", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
const generatedName = (
await dialog
.getByRole("button", { name: "Edit workflow name" })
.evaluate((button) => button.previousElementSibling?.textContent ?? "")
).trim();
expect(generatedName).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
await dialog.getByRole("button", { name: "Add first step" }).click();
await dialog.getByRole("button", { name: "Create workflow" }).click();
await expect(page.getByTestId("workflows-view")).toContainText(generatedName);
});
test("disables autocapitalization in the workflow form", async ({ page }) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await expect(dialog.getByLabel("Workflow name")).toHaveAttribute(
await selectWorkflowChannel(page, dialog);
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await expect(
dialog.getByRole("textbox", { name: "Workflow name" }),
).toHaveAttribute("autocapitalize", "off");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByRole("button", { name: "Step details" }).click();
await expect(dialog.getByLabel("Name (optional)")).toHaveAttribute(
"autocapitalize",
"off",
);
});
await dialog.getByRole("button", { name: "Add step" }).click();
await expect(dialog.getByLabel("Step name (optional)")).toHaveAttribute(
"autocapitalize",
"off",
test("shows executable guidance for diff trigger conditions", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await dialog.getByRole("button", { name: "Trigger event" }).click();
await page
.getByRole("menuitem", { name: "Diff Posted", exact: true })
.click();
await expect(dialog.getByLabel("Condition (optional)")).toHaveAttribute(
"placeholder",
'e.g. str_contains(trigger_text, "deploy")',
);
});
@@ -110,7 +266,7 @@ test("captures workflow library across responsive viewports", async ({
});
await createWorkflow(page, "Post the daily standup reminder to the team", {
description: "Keeps the team aligned every morning",
trigger: "schedule",
trigger: "message_posted",
});
await createWorkflow(
page,
@@ -152,8 +308,8 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
enabled: false,
trigger: "diff_posted",
stepName: "Notify reviewers",
stepCondition: 'str_contains(trigger_text, "src/")',
stepTimeoutSecs: "45",
stepConditionText: "src/",
stepTimeoutSecs: "45s",
});
const card = page
@@ -182,11 +338,6 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
await page.getByRole("button", { name: `View ${workflowName}` }).click();
const detailPanel = page.getByTestId("workflow-detail-panel");
await expect(detailPanel).toBeVisible();
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
await workflowActions().click();
await expect(enableItem).toHaveAttribute("aria-checked", "true");
await expect(enableItem.locator("button")).toHaveCount(0);
@@ -197,15 +348,11 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
await expect(
workflowCard().getByText("disabled", { exact: true }),
).toBeVisible();
await expect(
detailPanel.getByText("disabled", { exact: true }),
).toBeVisible();
await enableItem.click();
await expect(
workflowCard().getByText("active", { exact: true }),
).toBeVisible();
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
});
test("rejects a stale card toggle without overwriting a newer edit", async ({
@@ -309,12 +456,22 @@ test("shows the webhook secret dialog after saving a webhook workflow", async ({
trigger: "webhook",
});
await expect(page.getByText("Webhook Ready")).toBeVisible();
await expect(page.getByText("Webhook ready")).toBeVisible();
await expect(page.getByRole("button", { name: "Copy URL" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy Secret" })).toBeVisible();
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByText("Webhook Ready")).not.toBeVisible();
await expect(page.getByText(/•{24}/)).toBeVisible();
await page.goBack();
const confirmation = page.getByRole("alertdialog", {
name: "Continue without this secret?",
});
await expect(confirmation).toBeVisible();
await confirmation.getByRole("button", { name: "Go back" }).click();
await expect(page.getByText("Webhook ready")).toBeVisible();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await confirmation.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Webhook ready")).not.toBeVisible();
});
test("edits an existing workflow", async ({ page }) => {
@@ -332,13 +489,18 @@ test("edits an existing workflow", async ({ page }) => {
await page.getByRole("menuitem", { name: "Edit" }).click();
// Dialog should open in edit mode
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Edit Workflow")).toBeVisible();
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Edit workflow")).toBeVisible();
// Change the name
const nameInput = page.getByLabel("Workflow name");
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
const nameInput = dialog
.getByRole("textbox", { name: "Workflow name" })
.first();
await nameInput.clear();
await nameInput.fill(updatedName);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
// Save
await page.getByRole("button", { name: "Save" }).click();
@@ -362,8 +524,10 @@ test("duplicates a workflow", async ({ page }) => {
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
// Submit the duplicate
await page.getByRole("button", { name: "Create Copy" }).click();
// Submit the duplicate after deliberately choosing its destination channel.
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
await selectWorkflowChannel(page, dialog);
await dialog.getByRole("button", { name: "Create copy" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
// Both the original and copy should exist
@@ -396,35 +560,451 @@ test("deletes a workflow with confirmation", async ({ page }) => {
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
});
test("triggers a workflow from the detail panel", async ({ page }) => {
const workflowName = `trigger_test_${Date.now()}`;
test("deleting the open workflow closes its editor", async ({ page }) => {
const workflowName = `delete_open_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
await page.getByRole("button", { name: `View ${workflowName}` }).click();
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(detailDialog).toBeVisible();
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
await page.getByRole("button", { name: "Delete" }).click();
await expect(detailDialog).toHaveCount(0);
await expect(page).toHaveURL(/#\/workflows(?:\?|$)/);
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
});
test("rejected deletion keeps the confirmation, editor, and draft", async ({
page,
}) => {
const workflowName = `delete_rejected_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
await page.getByRole("button", { name: `View ${workflowName}` }).click();
const editor = page.getByRole("dialog", { name: "Edit workflow" });
await editor.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = editor.getByRole("textbox", { name: "Workflow YAML" });
await yamlEditor.fill(
(await yamlEditor.inputValue()).replace(
workflowName,
`${workflowName}_draft`,
),
);
await page.evaluate(() => {
window.__BUZZ_E2E__ ??= {};
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.workflowDeleteError = "relay refused deletion";
});
await editor.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
const confirmation = page.getByRole("alertdialog", {
name: "Delete workflow?",
});
await confirmation.getByRole("button", { name: "Delete" }).click();
await expect(confirmation).toBeVisible();
await expect(confirmation.getByRole("alert")).toContainText(
"relay refused deletion",
);
await confirmation.getByRole("button", { name: "Cancel" }).click();
await expect(editor).toBeVisible();
await expect(yamlEditor).toContainText(`${workflowName}_draft`);
});
test("captures the built editor at desktop and narrow widths", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, "editor_screenshot");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Notify the workflow channel");
const inspector = dialog.getByTestId("workflow-node-inspector");
for (const viewport of [
{ height: 900, name: "wide", width: 1440 },
{ height: 820, name: "narrow", width: 760 },
]) {
await page.setViewportSize(viewport);
await inspector.getByRole("button", { name: "Run controls" }).click();
await waitForAnimations(page);
await page.screenshot({
path: `test-results/workflow-editor-${viewport.name}-run-controls.png`,
});
await inspector.getByRole("button", { name: "Step details" }).click();
await waitForAnimations(page);
await page.screenshot({
path: `test-results/workflow-editor-${viewport.name}-step-details.png`,
});
await inspector.getByRole("button", { name: "Step details" }).click();
}
});
test("preserves final sequence affordances and responsive inspector behavior", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, "sequence_parity_test");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
const runControls = inspector.getByRole("button", { name: "Run controls" });
const stepDetails = inspector.getByRole("button", { name: "Step details" });
await expect(runControls).toHaveAttribute("aria-expanded", "false");
await expect(stepDetails).toHaveAttribute("aria-expanded", "false");
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
await expect(inspector.getByLabel("Name (optional)")).toHaveCount(0);
await runControls.click();
await expect(runControls).toHaveAttribute("aria-expanded", "true");
await inspector.getByLabel("Text to match").fill("deploy");
const timeoutField = inspector.getByRole("textbox", {
name: "Timeout",
exact: true,
});
const timeoutSlider = inspector.getByRole("slider", {
name: "Timeout slider",
});
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "5m");
await timeoutField.fill("1h 2s");
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "1h");
await timeoutField.fill("45s");
await expect(runControls).toContainText("Conditional · 45s");
await stepDetails.click();
await expect(runControls).toHaveAttribute("aria-expanded", "false");
await expect(stepDetails).toHaveAttribute("aria-expanded", "true");
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
await inspector.getByLabel("Name (optional)").fill("Notify deployers");
await expect(stepDetails).toContainText("Notify deployers");
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
const ingresses = dialog.getByTestId("workflow-node-ingress");
await expect(ingresses).toHaveCount(2);
await expect(
ingresses.first().locator("svg.lucide-arrow-down"),
).toBeVisible();
await expect(ingresses.last().locator("svg.lucide-arrow-down")).toHaveCount(
0,
);
await expect(
ingresses.last().getByRole("button", { name: "Add after Step 1" }),
).toBeVisible();
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
const removeStep = dialog.getByRole("button", { name: "Remove Step 1" });
await expect(removeStep).toHaveClass(/-right-8/);
await stepNode.hover();
await expect(removeStep).toBeVisible();
await page.setViewportSize({ width: 760, height: 820 });
await expect(
dialog.getByTestId("workflow-node-inspector-backdrop"),
).toBeVisible();
await dialog.getByTestId("workflow-node-inspector-backdrop").click({
position: { x: 20, y: 20 },
});
await expect(dialog.getByTestId("workflow-node-inspector")).not.toBeVisible();
});
test("pane routes use stable IDs and Form/YAML changes stay synchronized", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, "pane_sync_test");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("first message");
await expect(page).toHaveURL(/pane=step%3Astep_1/);
await dialog.getByRole("button", { name: "Add after Step 1" }).click();
await page.getByRole("menuitem", { name: "Delay" }).click();
await dialog.getByLabel("Duration").fill("5m");
await expect(page).toHaveURL(/pane=step%3Astep_2/);
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
await expect(page).toHaveURL(/pane=step%3Astep_1/);
await dialog
.getByTestId("workflow-node-inspector")
.getByRole("button", { name: "Remove step", exact: true })
.click();
await expect(page).toHaveURL(/pane=step%3Astep_2/);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
await expect(yamlEditor).toContainText("id: step_2");
await expect(yamlEditor).not.toContainText("id: step_1");
const yaml = await yamlEditor.inputValue();
await yamlEditor.fill(yaml.replace("duration: 5m", "duration: 10m"));
await dialog.getByRole("tab", { name: "Form" }).click();
await dialog.getByRole("button", { name: "Step 1: Delay" }).click();
await expect(dialog.getByLabel("Duration")).toHaveValue("10m");
});
test("clean editor close respects in-app versus direct-entry provenance", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
await expect(page).toHaveURL(/view=create/);
await page
.getByRole("dialog", { name: "Create workflow" })
.getByRole("button", {
name: "Close",
exact: true,
})
.click();
await expect(page).toHaveURL(/#\/workflows$/);
await page.goto("/#/workflows?view=create");
await page
.getByRole("dialog", { name: "Create workflow" })
.getByRole("button", {
name: "Close",
exact: true,
})
.click();
await expect(page).toHaveURL(/#\/workflows$/);
await page.goBack();
await expect(page).toHaveURL(/#\/workflows$/);
});
test("direct workflow routes survive refresh and invalid view opens detail", async ({
page,
}) => {
const workflowName = `route_test_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Edit" }).click();
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?.*view=edit/);
const workflowId = new URL(page.url()).hash.match(/workflows\/([^?]+)/)?.[1];
expect(workflowId).toBeTruthy();
await page.goto(`/#/workflows/${workflowId}?view=invalid`);
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(detailDialog).toBeVisible();
await expect(detailDialog).toContainText(workflowName);
await expect(
detailDialog.getByRole("button", { name: "Run history" }),
).toHaveCount(0);
await page.goto("/#/workflows?view=create");
await page.reload();
await expect(
page.getByRole("dialog", { name: "Create workflow" }),
).toBeVisible();
});
test("dirty create close is blocked and keep editing preserves the draft", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, "preserved_dirty_draft");
await dialog.getByRole("button", { name: "Close", exact: true }).click();
const confirmation = page.getByRole("alertdialog", {
name: "Discard changes?",
});
await expect(confirmation).toBeVisible();
await confirmation.getByRole("button", { name: "Keep editing" }).click();
await expect(dialog).toContainText("preserved_dirty_draft");
});
test("stale editor save preserves the local draft and reports the conflict", async ({
page,
}) => {
const workflowName = `stale_editor_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
const nameInput = dialog
.getByRole("textbox", { name: "Workflow name" })
.first();
await nameInput.fill(`${workflowName}_local`);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await page.evaluate(async () => {
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("mock command bridge unavailable");
const workflowId = new URL(window.location.href).hash.match(
/workflows\/([^?]+)/,
)?.[1];
if (!workflowId) throw new Error("workflow id unavailable");
const workflow = (await invoke("get_workflow", { workflowId })) as {
revision: string;
};
await invoke("update_workflow", {
expectedRevision: workflow.revision,
workflowId,
yamlDefinition:
"name: authoritative_remote\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: send_message\n text: remote\n",
});
});
await dialog.getByRole("button", { name: "Save changes" }).click();
await expect(dialog).toContainText("workflow changed since it was loaded");
await expect(dialog).toContainText(`${workflowName}_local`);
await expect(dialog).toBeVisible();
});
test("unsupported workflow opens canonical YAML without a fabricated sequence", async ({
page,
}) => {
const workflowName = `unsupported_detail_${Date.now()}`;
await navigateToWorkflows(page);
const workflowId = await page.evaluate(async (name) => {
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("mock command bridge unavailable");
const workflow = (await invoke("create_workflow", {
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
yamlDefinition: `name: ${name}\ntrigger:\n on: message_posted\n legacy_filter:\n author: alice\nsteps:\n - id: legacy_step\n action: send_message\n text: preserve me\n`,
})) as { id: string };
return workflow.id;
}, workflowName);
await page.goto(`/#/workflows/${workflowId}`);
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(detailDialog).toBeVisible();
await expect(detailDialog).toContainText(workflowName);
await expect(detailDialog.getByRole("tab", { name: "YAML" })).toHaveAttribute(
"aria-selected",
"true",
);
const yamlEditor = detailDialog.getByRole("textbox", {
name: "Workflow YAML",
});
await expect(yamlEditor).toContainText("legacy_filter:");
await expect(yamlEditor).toContainText("author: alice");
await expect(yamlEditor).toContainText("id: legacy_step");
await expect(yamlEditor).toContainText("text: preserve me");
await expect(
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
).toHaveCount(0);
await expect(
detailDialog.getByRole("button", { name: "Step 1: Send Message" }),
).toHaveCount(0);
});
test("card click opens the animated trigger inspector, triggers a run, and hides unsupported run history", async ({
page,
}) => {
const workflowName = `card_editor_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
// Click on the workflow card to open the detail panel
const inspectorEntryAnimation = page.evaluate(
() =>
new Promise<boolean>((resolve) => {
const timeoutId = window.setTimeout(() => {
observer.disconnect();
resolve(false);
}, 1_000);
const observer = new MutationObserver(() => {
const inspector = document.querySelector<HTMLElement>(
'[data-testid="workflow-node-inspector"]',
);
if (!inspector) return;
observer.disconnect();
window.clearTimeout(timeoutId);
const initialRect = inspector.getBoundingClientRect();
const initialStyles = getComputedStyle(inspector);
const initial = {
opacity: initialStyles.opacity,
transform: initialStyles.transform,
width: initialRect.width,
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const nextRect = inspector.getBoundingClientRect();
const nextStyles = getComputedStyle(inspector);
resolve(
nextRect.width !== initial.width ||
nextStyles.opacity !== initial.opacity ||
nextStyles.transform !== initial.transform,
);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}),
);
await page.getByRole("button", { name: `View ${workflowName}` }).click();
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
// Click the Trigger button
await page
.getByTestId("workflow-detail-panel")
.getByRole("button", { name: "Trigger" })
.click();
// Wait for the trigger to complete (button text changes back from "Triggering...")
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?pane=trigger$/);
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(detailDialog).toBeVisible();
await expect(detailDialog).toContainText(workflowName);
await expect(inspectorEntryAnimation).resolves.toBe(true);
await expect(page.getByTestId("workflow-node-inspector")).toBeVisible();
await expect(
page
.getByTestId("workflow-detail-panel")
.getByRole("button", { name: "Trigger" }),
).toBeVisible();
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
).toHaveAttribute("aria-pressed", "true");
const triggerCallsBefore = await page.evaluate(
() =>
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
(call) => call.command === "trigger_workflow",
).length ?? 0,
);
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Trigger" }).click();
await expect
.poll(async () =>
page.evaluate(
() =>
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
(call) => call.command === "trigger_workflow",
).length ?? 0,
),
)
.toBe(triggerCallsBefore + 1);
await expect(page.getByTestId("workflow-detail-panel")).toHaveCount(0);
await expect(
page
.getByTestId("workflow-detail-panel")
.getByTestId("workflow-selected-run"),
).toBeVisible();
await expect(
page.getByTestId("workflow-detail-panel").getByTestId("workflow-run-trace"),
).toContainText("step_1");
detailDialog.getByRole("button", { name: "Run history" }),
).toHaveCount(0);
});
test("missing workflow routes show an unavailable modal with close and retry", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.goto("/#/workflows/missing-workflow?view=edit");
const unavailable = page.getByRole("dialog", {
name: "Workflow unavailable",
});
await expect(unavailable).toBeVisible();
await expect(
unavailable.getByRole("button", { name: "Retry" }),
).toBeVisible();
await unavailable.getByRole("button", { name: "Close" }).click();
await expect(page).toHaveURL(/#\/workflows$/);
});