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 ( +
+ + Cron expression + +
+
+ {CRON_FIELD_DEFINITIONS.map((definition, index) => ( + + ))} +
+
+ {CRON_FIELD_DEFINITIONS.map((definition, index) => ( + { + 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]} + /> + ))} +
+
+

+ {firstError ?? + "UTC · Paste all 5 fields, or use wildcards, lists, ranges, and steps."} +

+
+ ); +} 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 (
- - - + 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 ? ( + + ) : null} - ) : null} - - + {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} + /> + +
+ ); + } + + return ( +
+ + {generating ? "Generating name…" : name || "Untitled workflow"} + + +
+ ); +} + 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 ( <> - - - - {TITLES[mode]} - - {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."} - - - -
- {showChannelSelector ? ( -
- Channel - { - mutation.reset(); - setSelectedChannelId(value); - }} - value={selectedChannelId} + + + +
+ + {TITLES[mode]} + + + {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."} + +
+ +
-

- {selectedChannel - ? `New workflows will belong to ${selectedChannel.name}.` - : "Join or create a channel before adding a workflow."} -

- ) : (showChannelInfo || mode === "edit") && selectedChannel ? ( -

- {mode === "edit" - ? "Editing workflow in" - : "This workflow will be created in"}{" "} - - {selectedChannel.name} - - . -

- ) : null} +
+
+ {mode === "edit" && workflowSnapshot ? ( + <> + + {/* TODO(workflow-run-history-capability): Restore this + icon-only entry point after Desktop gates it on the active + relay's advertised NIP-11 capabilities. + + + + */} + +
+

+ Workflow +

+

Run history

+
+
+ +
+
+
+ onDeleteWorkflow(workflowSnapshot)} + onDuplicate={() => + requestEditorTransition(() => + onDuplicateWorkflow(workflowSnapshot.id), + ) + } + onEdit={() => + requestEditorTransition(() => + onEditWorkflow(workflowSnapshot.id), + ) + } + onToggleEnabled={handleToggleWorkflowEnabled} + onTrigger={() => onTriggerWorkflow(workflowSnapshot.id)} + /> + + ) : null} + + + +
+ +
{ mutation.reset(); + yamlDefinitionRef.current = yaml; setYamlDefinition(yaml); }} + onSelectedNodeChange={onEditorPaneChange} + parseError={editorParseError} + ref={formBuilderRef} + scopeField={ + showChannelSelector ? ( +
+ setChannelAutoOpenPending(false)} + onChange={(value) => { + mutation.reset(); + setSelectedChannelId(value); + if (value) onEditorPaneChange({ type: "trigger" }); + }} + required + variant={editorMode === "yaml" ? "field" : "header"} + value={selectedChannelId} + /> + {channels.length === 0 ? ( +

+ Join or create a channel before adding a workflow. +

+ ) : null} +
+ ) : mode === "edit" && selectedChannel ? ( + + ) : null + } + selectedNode={ + mode === "create" && !selectedChannelId ? null : pane + } + workflowChannelId={selectedChannelId || null} yaml={yamlDefinition} /> - - {mutation.error instanceof Error ? ( -

- {mutation.error.message} -

- ) : null}
-
- - + {mutation.error.message} +

+ ) : null} + +
+ + + + Form + + + + YAML + + + +
+ + {isAddingFirstStep ? ( + + ) : ( + + )} +
+ { + setDiscardConfirmationOpen(nextOpen); + if (!nextOpen) { + pendingEditorTransitionRef.current = null; + } + if ( + !nextOpen && + navigationBlocker.status === "blocked" && + !proceedingNavigationRef.current + ) { + navigationBlocker.reset(); + } + }} + open={discardConfirmationOpen} + > + + + Discard changes? + + Your unsaved workflow changes will be lost. + + + + + + + + + + + + + + { + setSecretConfirmationOpen(nextOpen); + if ( + !nextOpen && + navigationBlocker.status === "blocked" && + !proceedingNavigationRef.current + ) { + navigationBlocker.reset(); + } + }} + open={secretConfirmationOpen} + > + + + Continue without this secret? + + This private webhook secret cannot be recovered. Copy and store it + before continuing, or explicitly leave it behind. + + + + + + + + + + + + + {savedWebhookInfo ? ( { - if (!nextOpen) { - setSavedWebhookInfo(null); - } - }} + onContinue={() => setSecretConfirmationOpen(true)} open relayHttpUrl={savedWebhookInfo.relayHttpUrl} + relayUrlError={savedWebhookInfo.relayUrlError} webhookSecret={savedWebhookInfo.webhookSecret} workflowId={savedWebhookInfo.workflowId} /> diff --git a/desktop/src/features/workflows/ui/WorkflowDurationField.tsx b/desktop/src/features/workflows/ui/WorkflowDurationField.tsx new file mode 100644 index 00000000000..5045134e1b4 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowDurationField.tsx @@ -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 ( +
+ {hideLabel ? ( + + ) : ( + {label} + )} +
+
+ + { + if (parsedSeconds !== null) { + onChange( + formatDurationSeconds( + Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds), + ), + ); + } + }} + onChange={(event) => onChange(event.target.value)} + placeholder={placeholder} + spellCheck={false} + value={value} + /> +
+
+ ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowEditorHost.tsx b/desktop/src/features/workflows/ui/WorkflowEditorHost.tsx new file mode 100644 index 00000000000..4bba68837c7 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowEditorHost.tsx @@ -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 ( + { + if (!open) onClose(); + }} + onRetry={() => void editorWorkflowQuery.refetch()} + open + /> + ); + } + + return ( + { + if (!open) onClose(); + }} + onTriggerWorkflow={onTriggerWorkflow} + open + pane={editor.pane} + workflow={editorWorkflow} + /> + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowEmojiField.tsx b/desktop/src/features/workflows/ui/WorkflowEmojiField.tsx new file mode 100644 index 00000000000..46e9bc33187 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowEmojiField.tsx @@ -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 ( +
+ + + + + + { + onChange(emoji); + setPickerOpen(false); + }} + /> + + + {value && clearAriaLabel ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index c2b7bfda1a7..fbd3a26bc64 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -1,53 +1,91 @@ -import { Code, Plus } from "lucide-react"; +import { + ArrowDown, + Check, + ChevronDown, + Plus, + Trash2, + X, + Zap, +} from "lucide-react"; +import { FocusScope } from "@radix-ui/react-focus-scope"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; +import { createPortal } from "react-dom"; +import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; -import { Checkbox } from "@/shared/ui/checkbox"; +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; +import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; +import { WorkflowEmojiField } from "./WorkflowEmojiField"; +import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEditor"; +import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; -import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; +import type { WorkflowEditorPane } from "./workflowEditorPane"; +import { FieldLabel } from "./workflowFormPrimitives"; import { DEFAULT_FORM_STATE, + ACTION_LABELS, + SELECTABLE_ACTION_TYPES, + SELECTABLE_TRIGGER_TYPES, TRIGGER_LABELS, - TRIGGER_TYPES, formStateToYaml, nextStepId, + supportsMessageTextCondition, yamlToFormState, } from "./workflowFormTypes"; +import { defaultScheduleTrigger } from "./workflowSchedule"; +import { readWorkflowDocumentFields } from "./workflowYamlDocument"; import type { + ActionType, StepFormState, TriggerConfig, - TriggerType, WorkflowFormState, } from "./workflowFormTypes"; function TriggerConfigFields({ + disabled, trigger, onUpdate, }: { + disabled?: boolean; trigger: TriggerConfig; onUpdate: (trigger: TriggerConfig) => void; }) { switch (trigger.on) { case "message_posted": + return ( + onUpdate({ ...trigger, filter })} + value={trigger.filter ?? ""} + /> + ); case "diff_posted": return (
- Filter expression (optional) + Condition (optional) onUpdate({ ...trigger, filter: event.target.value }) } - placeholder='e.g. contains(text, "deploy")' + placeholder='e.g. str_contains(trigger_text, "deploy")' value={trigger.filter ?? ""} />

- Evalexpr filter — leave empty to trigger on all matching events. + Evalexpr. Empty matches all events.

); @@ -57,61 +95,32 @@ function TriggerConfigFields({ Emoji filter (optional) - - onUpdate({ ...trigger, emoji: event.target.value }) - } - placeholder="e.g. thumbsup" + onChange={(emoji) => onUpdate({ ...trigger, emoji })} value={trigger.emoji ?? ""} />

- Leave empty to trigger on any reaction. + Empty matches any reaction.

); case "webhook": return (

- A unique webhook URL will be generated when the workflow is created. + A unique URL is generated after creation.

); case "schedule": return ( -
-
- - Cron expression (optional) - - - onUpdate({ ...trigger, cron: event.target.value }) - } - placeholder="e.g. 0 9 * * 1-5 (weekdays at 9am UTC)" - value={trigger.cron ?? ""} - /> -
-
- - Interval (optional) - - - onUpdate({ ...trigger, interval: event.target.value }) - } - placeholder="e.g. 1h, 30m" - value={trigger.interval ?? ""} - /> -
-

- Provide either a cron expression or a simple interval. -

-
+ ); default: return null; @@ -119,67 +128,411 @@ function TriggerConfigFields({ } type WorkflowFormBuilderProps = { + channels: Channel[]; disabled?: boolean; + nameLeadingContainer?: HTMLElement | null; + mode: WorkflowEditorMode; onChange: (yaml: string) => void; + onSelectedNodeChange: (pane: WorkflowEditorPane) => void; + parseError: string | null; + scopeField?: React.ReactNode; + selectedNode: WorkflowEditorPane; + workflowChannelId?: string | null; yaml: string; }; -export function WorkflowFormBuilder({ +export type WorkflowFormBuilderHandle = { + addFirstStep: () => void; + synchronizeYaml: (yaml: string) => void; +}; + +export type WorkflowEditorMode = "form" | "yaml"; + +function nodePosition( + node: Exclude, + steps: StepFormState[], +): number { + if (node.type === "trigger") return 0; + const index = steps.findIndex((step) => step.id === node.stepId); + return index < 0 ? 0 : index + 1; +} + +const inspectorContentVariants = { + enter: (direction: number) => ({ + opacity: 0, + y: direction < 0 ? 12 : -12, + }), + center: { opacity: 1, y: 0 }, + exit: (direction: number) => ({ + opacity: 0, + y: direction < 0 ? -12 : 12, + }), +}; + +function InspectorTypeMenu({ + ariaLabel, disabled, + labels, onChange, - yaml, -}: WorkflowFormBuilderProps) { + options, + value, +}: { + ariaLabel: string; + disabled?: boolean; + labels: Record; + onChange: (value: T) => void; + options: readonly T[]; + value: T; +}) { + return ( + + + + + + {options.map((option) => ( + onChange(option)}> + + {labels[option]} + + ))} + + + ); +} + +function WorkflowNode({ + description, + disabled, + icon, + label, + number, + onAddAfter, + onClick, + onRemove, + selected, + showTitle = true, + subtitle, + terminal, + title, +}: { + description: string; + disabled?: boolean; + icon?: React.ReactNode; + label: string; + number?: number; + onAddAfter: (action: ActionType) => void; + onClick: () => void; + onRemove?: () => void; + selected: boolean; + showTitle?: boolean; + subtitle?: string; + terminal: boolean; + title: string; +}) { + const isNumbered = number !== undefined; + const [addMenuOpen, setAddMenuOpen] = React.useState(false); + + return ( +
  • +
    + + + {onRemove ? ( + + ) : null} +
    + +
    + {terminal ? null : ( +
    +
  • + ); +} + +export const WorkflowFormBuilder = React.forwardRef< + WorkflowFormBuilderHandle, + WorkflowFormBuilderProps +>(function WorkflowFormBuilder( + { + channels: _channels, + disabled, + nameLeadingContainer, + mode, + onChange, + onSelectedNodeChange, + parseError, + scopeField, + selectedNode: selectedRouteNode, + workflowChannelId, + yaml, + }, + ref, +) { // Parse once on mount instead of calling yamlToFormState three times const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null); - const [mode, setMode] = React.useState<"form" | "yaml">( - initialParseRef.current === null || initialParseRef.current.ok - ? "form" - : "yaml", - ); const [formState, setFormState] = React.useState( initialParseRef.current?.ok ? initialParseRef.current.state : DEFAULT_FORM_STATE, ); - const [parseError, setParseError] = React.useState( - initialParseRef.current !== null && !initialParseRef.current.ok - ? initialParseRef.current.error - : null, - ); + const selectedNode = + selectedRouteNode?.type === "trigger" || + (selectedRouteNode?.type === "step" && + formState.steps.some((step) => step.id === selectedRouteNode.stepId)) + ? selectedRouteNode + : null; + const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1); + const [narrowInspector, setNarrowInspector] = React.useState(false); + const containerRef = React.useRef(null); + const shouldReduceMotion = useReducedMotion(); + const previousModeRef = React.useRef(mode); + const lastSynchronizedYamlRef = React.useRef(yaml); + const canonicalYamlRef = React.useRef(yaml); + const pendingPaneReconciliationRef = React.useRef(null); + + React.useLayoutEffect(() => { + const container = containerRef.current; + if (!container || typeof ResizeObserver === "undefined") return; + const update = () => setNarrowInspector(container.clientWidth <= 58 * 16); + update(); + const observer = new ResizeObserver(update); + observer.observe(container); + return () => observer.disconnect(); + }, []); const updateFormState = React.useCallback( (next: WorkflowFormState) => { + const nextYaml = formStateToYaml(next); + lastSynchronizedYamlRef.current = nextYaml; + canonicalYamlRef.current = nextYaml; setFormState(next); - onChange(formStateToYaml(next)); + onChange(nextYaml); }, [onChange], ); - const handleToggleMode = React.useCallback(() => { - if (mode === "form") { - setMode("yaml"); - setParseError(null); - } else { - const result = yamlToFormState(yaml); - if (result.ok) { - setFormState(result.state); - setParseError(null); - setMode("form"); - } else { - setParseError(result.error); - } + React.useEffect(() => { + if (previousModeRef.current === mode) return; + previousModeRef.current = mode; + + if (mode === "yaml") { + onSelectedNodeChange(null); + return; } - }, [mode, yaml]); - const addStep = React.useCallback(() => { - updateFormState({ - ...formState, - steps: [ - ...formState.steps, - { id: nextStepId(formState.steps), action: "delay" }, - ], + const result = yamlToFormState(yaml); + if (result.ok) { + setFormState(result.state); + lastSynchronizedYamlRef.current = yaml; + } + }, [mode, onSelectedNodeChange, yaml]); + + React.useLayoutEffect(() => { + canonicalYamlRef.current = yaml; + if (mode !== "form" || yaml === lastSynchronizedYamlRef.current) return; + const result = yamlToFormState(yaml); + if (result.ok) { + setFormState(result.state); + lastSynchronizedYamlRef.current = yaml; + return; + } + + // The header can rename or disable a definition whose body is still + // incomplete — a step that has no message text yet fails form validation. + // Adopt those fields anyway, otherwise the next form edit re-serializes the + // values this state was holding before the header wrote them. + const header = readWorkflowDocumentFields(yaml); + if (!header.editable) return; + lastSynchronizedYamlRef.current = yaml; + setFormState((current) => { + const name = header.name ?? current.name; + const enabled = header.enabled !== false; + return name === current.name && enabled === current.enabled + ? current + : { ...current, enabled, name }; }); - }, [formState, updateFormState]); + }, [mode, yaml]); + + React.useEffect(() => { + if (pendingPaneReconciliationRef.current) { + if ( + selectedRouteNode?.type === pendingPaneReconciliationRef.current.type && + (selectedRouteNode?.type !== "step" || + (pendingPaneReconciliationRef.current.type === "step" && + selectedRouteNode.stepId === + pendingPaneReconciliationRef.current.stepId)) + ) { + pendingPaneReconciliationRef.current = null; + } + return; + } + if ( + mode === "form" && + selectedRouteNode?.type === "step" && + !formState.steps.some((step) => step.id === selectedRouteNode.stepId) + ) { + onSelectedNodeChange(null); + } + }, [formState.steps, mode, onSelectedNodeChange, selectedRouteNode]); + + const selectNode = React.useCallback( + (nextNode: Exclude) => { + if (selectedNode) { + const currentPosition = nodePosition(selectedNode, formState.steps); + const nextPosition = nodePosition(nextNode, formState.steps); + if (nextPosition !== currentPosition) { + setSelectionDirection(nextPosition < currentPosition ? -1 : 1); + } + } + onSelectedNodeChange(nextNode); + }, + [formState.steps, onSelectedNodeChange, selectedNode], + ); + + const insertStep = React.useCallback( + (index: number, action: ActionType) => { + const synchronizedState = yamlToFormState(canonicalYamlRef.current); + const sourceState = synchronizedState.ok + ? synchronizedState.state + : formState; + const nextSteps = [...sourceState.steps]; + const newStep: StepFormState = { + id: nextStepId(sourceState.steps), + action, + }; + if (action === "call_webhook") { + newStep.method = "POST"; + } + nextSteps.splice(index, 0, newStep); + updateFormState({ + ...sourceState, + steps: nextSteps, + }); + selectNode({ type: "step", stepId: newStep.id }); + }, + [formState, selectNode, updateFormState], + ); + + React.useImperativeHandle( + ref, + () => ({ + addFirstStep: () => insertStep(0, "send_message"), + synchronizeYaml: (nextYaml: string) => { + const result = yamlToFormState(nextYaml); + if (!result.ok) return; + lastSynchronizedYamlRef.current = nextYaml; + canonicalYamlRef.current = nextYaml; + setFormState(result.state); + }, + }), + [insertStep], + ); const removeStep = React.useCallback( (index: number) => { @@ -187,173 +540,339 @@ export function WorkflowFormBuilder({ ...formState, steps: formState.steps.filter((_, i) => i !== index), }); + + if (selectedNode?.type !== "step") return; + const selectedIndex = formState.steps.findIndex( + (step) => step.id === selectedNode.stepId, + ); + + if (selectedIndex === index) { + const fallbackPane = + index > 0 + ? { type: "step" as const, stepId: formState.steps[index - 1].id } + : formState.steps[index + 1] + ? { + type: "step" as const, + stepId: formState.steps[index + 1].id, + } + : ({ type: "trigger" } as const); + pendingPaneReconciliationRef.current = fallbackPane; + setSelectionDirection(-1); + onSelectedNodeChange(fallbackPane); + } }, - [formState, updateFormState], + [formState, onSelectedNodeChange, selectedNode, updateFormState], ); const updateStep = React.useCallback( (index: number, step: StepFormState) => { + const previousStep = formState.steps[index]; const next = [...formState.steps]; next[index] = step; updateFormState({ ...formState, steps: next }); + if ( + selectedNode?.type === "step" && + previousStep?.id === selectedNode.stepId && + step.id !== previousStep.id + ) { + onSelectedNodeChange({ type: "step", stepId: step.id }); + } }, - [formState, updateFormState], + [formState, onSelectedNodeChange, selectedNode, updateFormState], ); - return ( -
    -
    - -
    + const selectedStep = + selectedNode?.type === "step" + ? formState.steps.find((step) => step.id === selectedNode.stepId) + : undefined; + const selectedStepIndex = selectedStep + ? formState.steps.findIndex((step) => step.id === selectedStep.id) + : -1; - {parseError ? ( -

    - Cannot switch to form view: {parseError} -

    - ) : null} - - {mode === "yaml" ? ( -
    -