diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs
index fe8b477ba40..5c17b34e10d 100644
--- a/crates/buzz-workflow/src/lib.rs
+++ b/crates/buzz-workflow/src/lib.rs
@@ -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()
diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs
index 9bc79aa48b3..afee730d601 100644
--- a/crates/buzz-workflow/src/schema.rs
+++ b/crates/buzz-workflow/src/schema.rs
@@ -47,6 +47,9 @@ pub enum TriggerDef {
/// Optional: only fire for this specific emoji.
#[serde(default)]
emoji: Option,
+ /// Optional evalexpr filter over the reaction context.
+ #[serde(default)]
+ filter: Option,
},
/// 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:?}"),
}
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 0a3c49aa2f9..b1a2d5623b2 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -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",
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index cde2465c4ba..8282b425f55 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -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,213 +765,219 @@ export function AppShell() {
data-testid="app-sidebar-layer"
>
- {!settingsOpen && !isHuddleRoom ? (
-
- ) : null}
- {settingsOpen ? (
-
-
-
-
-
- ) : (
-
- {!isHuddleRoom ? (
-
{
- 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,
+
+ {!settingsOpen && !isHuddleRoom ? (
+
+ ) : null}
+ {settingsOpen ? (
+
+
+
+
+
+ ) : (
+
+ {!isHuddleRoom ? (
+
{
+ 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}
- 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}
-
-
- }
+ 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}
+ 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}
+
-
-
-
- {!isHuddleRoom ? (
-
- ) : null}
-
- )}
-
-
- {
- setIsChannelManagementOpen(open);
- if (!open) {
- setManagedChannelId(null);
+
+ }
+ >
+
+
+
+ {!isHuddleRoom ? (
+
+ ) : null}
+
+ )}
+
+
+ {
- setIsChannelManagementOpen(false);
- setManagedChannelId(null);
- void goHome({ replace: true });
- }}
- onSelectChannel={(channelId) => {
- void goChannel(channelId);
- }}
- relayUrl={communitiesHook.activeCommunity?.relayUrl}
- />
-
+ 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}
+ />
+
+
diff --git a/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx b/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx
new file mode 100644
index 00000000000..8d53d20badb
--- /dev/null
+++ b/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx
@@ -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(null);
+ const [workflowHint, setWorkflowHint] = React.useState(
+ undefined,
+ );
+ const [deleteTarget, setDeleteTarget] = React.useState(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 (
+
+ {children}
+
+ {
+ if (!open) {
+ deleteMutation.reset();
+ setDeleteTarget(null);
+ }
+ }}
+ open={deleteTarget !== null}
+ workflow={deleteTarget}
+ />
+
+ );
+}
diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts
index 2203aa03a6a..53db19d3789 100644
--- a/desktop/src/app/navigation/useAppNavigation.ts
+++ b/desktop/src/app/navigation/useAppNavigation.ts
@@ -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,
diff --git a/desktop/src/app/routes/WorkflowsRouteScreen.tsx b/desktop/src/app/routes/WorkflowsRouteScreen.tsx
index 0a0a4dfb367..193695f0cd2 100644
--- a/desktop/src/app/routes/WorkflowsRouteScreen.tsx
+++ b/desktop/src/app/routes/WorkflowsRouteScreen.tsx
@@ -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 (
{
+ 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}
/>
);
}
diff --git a/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts b/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts
new file mode 100644
index 00000000000..8def7e65024
--- /dev/null
+++ b/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts
@@ -0,0 +1,6 @@
+import * as React from "react";
+
+export const LazyWorkflowsRouteScreen = React.lazy(async () => {
+ const module = await import("./WorkflowsRouteScreen");
+ return { default: module.WorkflowsRouteScreen };
+});
diff --git a/desktop/src/app/routes/workflows.$workflowId.tsx b/desktop/src/app/routes/workflows.$workflowId.tsx
index f6a74aa15d1..71e62c658f3 100644
--- a/desktop/src/app/routes/workflows.$workflowId.tsx
+++ b/desktop/src/app/routes/workflows.$workflowId.tsx
@@ -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) => ({
+ 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 (
}>
-
+ {
+ void navigate({
+ replace: true,
+ resetScroll: false,
+ search: {
+ pane: serializeWorkflowEditorPane(nextPane),
+ view,
+ },
+ });
+ }}
+ />
);
}
diff --git a/desktop/src/app/routes/workflows.tsx b/desktop/src/app/routes/workflows.tsx
index 7ab6461fd0b..7b8d5ad0d00 100644
--- a/desktop/src/app/routes/workflows.tsx
+++ b/desktop/src/app/routes/workflows.tsx
@@ -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) => ({
+ 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 (
}>
-
+ {
+ void navigate({
+ replace: true,
+ resetScroll: false,
+ search: {
+ channel,
+ pane: serializeWorkflowEditorPane(nextPane),
+ view,
+ },
+ });
+ }}
+ />
);
}
diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
index 566cfa3fabe..aea0f9323ec 100644
--- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
+++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
@@ -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()}
>
= {
};
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;
+ };
+ onCreateWorkflow: () => void;
+ onOpenWorkflow: (workflow: Workflow) => void;
deleteChannelMutation: ChannelMutation;
detailsError: unknown;
handleDeleteChannel: () => Promise;
@@ -598,7 +659,9 @@ type ChannelManagementPanelContentProps = {
onOpenMembers?: () => void;
onOpenChange: (open: boolean) => void;
resolvedChannel: Channel;
- setActiveView: React.Dispatch>;
+ 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
}
>
- {activeView === "canvas" ? "Canvas" : "Channel Settings"}
+ {activeView === "canvas"
+ ? "Canvas"
+ : activeView === "workflows"
+ ? "Workflows"
+ : "Channel Settings"}
@@ -749,14 +822,45 @@ function ChannelManagementPanelContent({
{canOpenCanvas ? (
+
+ setActiveView("canvas")}
+ testId="channel-canvas-ingress"
+ trailing={canvasQuery.isLoading ? "Loading..." : undefined}
+ />
+ {workflowsEnabled ? (
+ setActiveView("workflows")}
+ testId="channel-workflows-ingress"
+ trailing={
+ workflowsQuery.isLoading ? "Loading..." : undefined
+ }
+ />
+ ) : null}
+
+ ) : workflowsEnabled ? (
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({
) : null}
- ) : (
+ ) : activeView === "canvas" ? (
- )}
+ ) : activeView === "workflows" && workflowsEnabled ? (
+ void workflowsQuery.refetch()}
+ workflows={workflowsQuery.data ?? []}
+ />
+ ) : null}
);
diff --git a/desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx b/desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx
new file mode 100644
index 00000000000..a30392ca6c8
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx
@@ -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 (
+
+ {loading ? (
+
+ Loading workflows...
+
+ ) : error instanceof Error ? (
+
+
{error.message}
+
+
+ ) : workflows.length > 0 ? (
+
+ {workflows.map((workflow) => (
+
+ ))}
+
+ ) : (
+
+ No workflows in this channel yet.
+
+ )}
+
+
+
+ );
+}
diff --git a/desktop/src/features/workflows/ui/ChannelCombobox.tsx b/desktop/src/features/workflows/ui/ChannelCombobox.tsx
index 11fb4327f82..029286bad53 100644
--- a/desktop/src/features/workflows/ui/ChannelCombobox.tsx
+++ b/desktop/src/features/workflows/ui/ChannelCombobox.tsx
@@ -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 (
+
+ );
}
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 (
+
+
+
+
+ {readOnlyTooltip}
+
+ );
+ }
+
return (
-
+
+ {allowEmpty && !query ? (
+
+ ) : null}
{filtered.length === 0 ? (
No channels found.
) : (
- filtered.map((channel, index) => (
-
- ))
+
+
+ );
+ })
)}
-
+
);
diff --git a/desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx b/desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx
deleted file mode 100644
index 6e381db663e..00000000000
--- a/desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/desktop/src/features/workflows/ui/CronExpressionInput.tsx b/desktop/src/features/workflows/ui/CronExpressionInput.tsx
new file mode 100644
index 00000000000..5d5f6bfdfba
--- /dev/null
+++ b/desktop/src/features/workflows/ui/CronExpressionInput.tsx
@@ -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(() =>
+ cronFieldsFromExpression(value),
+ );
+ const [pasteError, setPasteError] = React.useState(null);
+ const inputRefs = React.useRef>([]);
+ 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 (
+
+ );
+}
diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx
index 2ca345fb011..3d3044d63d8 100644
--- a/desktop/src/features/workflows/ui/WorkflowCard.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx
@@ -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 (
onSelect(workflow.id)}
+ onClick={() => onView(workflow)}
type="button"
>
View {workflow.name}
@@ -159,7 +156,7 @@ export function WorkflowCard({
{triggerSummary}
) : null}
-
+
{workflow.name}
{description ? (
diff --git a/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx
index db07470f2cd..1c0b1aeb051 100644
--- a/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx
@@ -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;
onOpenChange: (open: boolean) => void;
};
export function WorkflowDeleteDialog({
+ error,
+ isPending = false,
open,
workflow,
onConfirm,
onOpenChange,
}: WorkflowDeleteDialogProps) {
return (
-
+ {
+ if (!isPending) onOpenChange(nextOpen);
+ }}
+ open={open}
+ >
Delete workflow?
@@ -34,26 +42,35 @@ export function WorkflowDeleteDialog({
? `Delete "${workflow.name}". This will stop all future triggers and remove the workflow permanently.`
: "Delete this workflow."}
+ {error ? (
+
+ Couldn’t delete workflow. {error} Try again or cancel to keep
+ editing.
+
+ ) : null}
-
+
Cancel
-
- {
- if (workflow) {
- onConfirm(workflow);
- }
- }}
- type="button"
- variant="destructive"
- >
- Delete
-
-
+ {
+ if (workflow) {
+ void onConfirm(workflow);
+ }
+ }}
+ type="button"
+ variant="destructive"
+ >
+ {isPending ? "Deleting…" : "Delete"}
+
diff --git a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx b/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx
index 3bc94a89868..2da8645cc2b 100644
--- a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx
@@ -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 (
-
-
-
- {workflow ? (
-
- {workflow.name}
-
- ) : (
-
- )}
- {workflowStatus ?
: null}
+ {showHeader ? (
+
+
+
+ {workflow ? (
+
+ {workflow.name}
+
+ ) : (
+
+ )}
+ {workflowStatus ? (
+
+ ) : null}
+
+ {workflowDescription ? (
+
+ {workflowDescription}
+
+ ) : workflowQuery.isLoading ? (
+
+ ) : null}
+ {triggerSummary ? (
+
+ {triggerSummary}
+
+ ) : workflowQuery.isLoading ? (
+
+ ) : null}
- {workflowDescription ? (
-
- {workflowDescription}
-
- ) : workflowQuery.isLoading ? (
-
- ) : null}
- {triggerSummary ? (
-
- {triggerSummary}
-
- ) : workflowQuery.isLoading ? (
-
- ) : null}
-
-
- {workflow ? (
+
+ {workflow && onEdit ? (
+
onEdit(workflow)}
+ size="sm"
+ variant="outline"
+ >
+
+ Edit
+
+ ) : null}
onEdit(workflow)}
+ disabled={triggerMutation.isPending || workflowQuery.isLoading}
+ onClick={() => void handleTrigger()}
size="sm"
variant="outline"
>
-
- Edit
+
+ {triggerMutation.isPending ? "Triggering..." : "Trigger"}
- ) : null}
-
void handleTrigger()}
- size="sm"
- variant="outline"
- >
-
- {triggerMutation.isPending ? "Triggering..." : "Trigger"}
-
-
-
-
+ {onClose ? (
+
+
+
+ ) : null}
+
-
+ ) : null}
{triggerMutation.isError ? (
{workflow ? (
-
-
-
- Definition
-
-
- {JSON.stringify(workflow.definition, null, 2)}
-
-
+
+ {showDefinition ? (
+
+
+ Definition
+
+
+ {JSON.stringify(workflow.definition, null, 2)}
+
+
+ ) : null}
-
- Run History
-
+ {showHeader ? (
+
+ Run History
+
+ ) : null}
{runsQuery.isError ? (
Failed to load workflow
) : (
-
+
diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx
index 5ce3a0d2ddb..facc82cc809 100644
--- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx
@@ -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
= {
- create: "Create Workflow",
- edit: "Edit Workflow",
- duplicate: "Duplicate Workflow",
+ create: "Create workflow",
+ edit: "Edit workflow",
+ duplicate: "Duplicate workflow",
};
const SUBMIT_LABELS: Record = {
- create: "Create",
- edit: "Save",
- duplicate: "Create Copy",
+ create: "Create workflow",
+ edit: "Save changes",
+ duplicate: "Create copy",
};
const PENDING_LABELS: Record = {
- 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(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 (
+
+ 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}
+ />
+ {
+ // 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"
+ >
+
+
+
+ );
+ }
+
+ return (
+
+
+ {generating ? "Generating name…" : name || "Untitled workflow"}
+
+
changeEditing(true)}
+ size="icon-xs"
+ title="Edit workflow name"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ );
+}
+
export function WorkflowDialog({
channels,
+ initialChannelId,
mode,
+ onDeleteWorkflow,
+ onDuplicateWorkflow,
+ onEditWorkflow,
+ onEditorPaneChange,
onOpenChange,
+ onTriggerWorkflow,
open,
+ pane,
workflow,
}: WorkflowDialogProps) {
+ const formBuilderRef = React.useRef(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(() =>
+ getInitialEditorMode(getInitialYaml(mode, workflowSnapshot)),
+ );
+ const [editorParseError, setEditorParseError] = React.useState(
+ 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(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,466 @@ 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 (
<>
-