[codex] Add workflow UI parity (#250)

This commit is contained in:
Wes
2026-04-06 11:41:52 -07:00
committed by GitHub
parent 53c2223ba5
commit c0421a50ed
17 changed files with 859 additions and 136 deletions

View File

@@ -1,7 +1,11 @@
use nostr::{EventBuilder, JsonUtil, Kind, Tag, ToBech32};
use tauri::State;
use crate::{app_state::AppState, models::IdentityInfo, relay::relay_ws_url};
use crate::{
app_state::AppState,
models::IdentityInfo,
relay::{relay_api_base_url, relay_ws_url},
};
#[tauri::command]
pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String> {
@@ -28,6 +32,11 @@ pub fn get_relay_ws_url() -> String {
relay_ws_url()
}
#[tauri::command]
pub fn get_relay_http_url() -> String {
relay_api_base_url()
}
#[tauri::command]
pub fn sign_event(
kind: u16,

View File

@@ -345,6 +345,7 @@ pub fn run() {
get_presence,
set_presence,
get_relay_ws_url,
get_relay_http_url,
discover_acp_providers,
discover_managed_agent_prereqs,
sign_event,

View File

@@ -16,6 +16,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowTriggerSummary,
} from "./workflowDefinition";
type WorkflowCardProps = {
workflow: Workflow;
@@ -52,6 +57,10 @@ export function WorkflowCard({
onDuplicate,
onDelete,
}: WorkflowCardProps) {
const displayStatus = getWorkflowDisplayStatus(workflow);
const description = getWorkflowDescription(workflow.definition);
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
return (
<div
className="relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50"
@@ -72,15 +81,21 @@ export function WorkflowCard({
<span className="truncate text-sm font-medium">
{workflow.name}
</span>
<StatusBadge status={workflow.status} />
<StatusBadge status={displayStatus} />
</div>
<div className="mt-1.5 flex items-center gap-3 pl-6 text-[11px] text-muted-foreground">
{channelName ? <span>{channelName}</span> : null}
{triggerSummary ? <span>{triggerSummary}</span> : null}
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>
</div>
{description ? (
<p className="mt-2 pl-6 text-xs text-muted-foreground">
{description}
</p>
) : null}
</div>
<DropdownMenu>

View File

@@ -10,6 +10,11 @@ import {
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
import type { Workflow } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowTriggerSummary,
} from "./workflowDefinition";
type WorkflowDetailPanelProps = {
workflowId: string;
@@ -33,6 +38,13 @@ export function WorkflowDetailPanel({
? (runs.find((r) => r.id === selectedRunId) ?? null)
: null;
const approvalsQuery = useRunApprovalsQuery(workflowId, selectedRunId);
const workflowDescription = workflow
? getWorkflowDescription(workflow.definition)
: null;
const triggerSummary = workflow
? getWorkflowTriggerSummary(workflow.definition)
: null;
const workflowStatus = workflow ? getWorkflowDisplayStatus(workflow) : null;
async function handleTrigger() {
try {
@@ -49,9 +61,24 @@ export function WorkflowDetailPanel({
data-testid="workflow-detail-panel"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<h3 className="truncate text-sm font-semibold">
{workflow?.name ?? "Loading..."}
</h3>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold">
{workflow?.name ?? "Loading..."}
</h3>
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
</div>
{workflowDescription ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{workflowDescription}
</p>
) : null}
{triggerSummary ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{triggerSummary}
</p>
) : null}
</div>
<div className="flex items-center gap-1">
{workflow ? (
<Button
@@ -165,6 +192,9 @@ export function WorkflowDetailPanel({
function RunStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
active: "bg-green-500/15 text-green-500",
disabled: "bg-muted text-muted-foreground",
archived: "bg-amber-500/15 text-amber-500",
completed: "bg-green-500/15 text-green-500",
failed: "bg-red-500/15 text-red-500",
running: "bg-blue-500/15 text-blue-500",

View File

@@ -6,6 +6,7 @@ import {
useUpdateWorkflowMutation,
} from "@/features/workflows/hooks";
import type { Channel, Workflow } from "@/shared/api/types";
import { getRelayHttpUrl } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -16,6 +17,7 @@ import {
} from "@/shared/ui/dialog";
import { ChannelCombobox } from "./ChannelCombobox";
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
import { FieldLabel } from "./workflowFormPrimitives";
type DialogMode = "create" | "edit" | "duplicate";
@@ -74,6 +76,11 @@ export function WorkflowDialog({
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
getInitialYaml(mode, workflow),
);
const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{
relayHttpUrl: string;
webhookSecret: string;
workflowId: string;
} | null>(null);
const createMutation = useCreateWorkflowMutation(selectedChannelId);
const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? "");
@@ -96,6 +103,7 @@ export function WorkflowDialog({
: defaultChannelId;
setSelectedChannelId(newChannelId);
setYamlDefinition(getInitialYaml(mode, workflow));
setSavedWebhookInfo(null);
resetCreate();
resetUpdate();
}
@@ -124,8 +132,16 @@ export function WorkflowDialog({
if (!selectedChannelId || !yamlDefinition.trim()) return;
try {
await mutation.mutateAsync(yamlDefinition);
const saved = await mutation.mutateAsync(yamlDefinition);
handleOpenChange(false);
if (saved.webhookSecret) {
const relayHttpUrl = await getRelayHttpUrl();
setSavedWebhookInfo({
relayHttpUrl,
webhookSecret: saved.webhookSecret,
workflowId: saved.workflow.id,
});
}
} catch {
// React Query stores the error; keep the dialog open.
}
@@ -135,86 +151,104 @@ export function WorkflowDialog({
const showChannelInfo = mode !== "edit" && channels.length === 1;
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{TITLES[mode]}</DialogTitle>
<DialogDescription>
{mode === "edit"
? "Modify the workflow definition."
: channels.length === 1
? "Create a workflow scoped to this channel."
: "Define a workflow and assign it to a channel."}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
{showChannelSelector ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
<ChannelCombobox
channels={channels}
disabled={mutation.isPending}
id="wf-channel-select"
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
}}
value={selectedChannelId}
/>
<p className="text-xs text-muted-foreground">
{selectedChannel
? `New workflows will belong to ${selectedChannel.name}.`
: "Join or create a channel before adding a workflow."}
</p>
</div>
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
<p className="text-sm text-muted-foreground">
<>
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{TITLES[mode]}</DialogTitle>
<DialogDescription>
{mode === "edit"
? "Editing workflow in"
: "This workflow will be created in"}{" "}
<span className="font-medium text-foreground">
{selectedChannel.name}
</span>
.
</p>
) : null}
? "Modify the workflow definition."
: channels.length === 1
? "Create a workflow scoped to this channel."
: "Define a workflow and assign it to a channel."}
</DialogDescription>
</DialogHeader>
<WorkflowFormBuilder
disabled={mutation.isPending}
onChange={(yaml) => {
mutation.reset();
setYamlDefinition(yaml);
}}
yaml={yamlDefinition}
/>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
{showChannelSelector ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
<ChannelCombobox
channels={channels}
disabled={mutation.isPending}
id="wf-channel-select"
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
}}
value={selectedChannelId}
/>
<p className="text-xs text-muted-foreground">
{selectedChannel
? `New workflows will belong to ${selectedChannel.name}.`
: "Join or create a channel before adding a workflow."}
</p>
</div>
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
<p className="text-sm text-muted-foreground">
{mode === "edit"
? "Editing workflow in"
: "This workflow will be created in"}{" "}
<span className="font-medium text-foreground">
{selectedChannel.name}
</span>
.
</p>
) : null}
{mutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mutation.error.message}
</p>
) : null}
</div>
<WorkflowFormBuilder
disabled={mutation.isPending}
onChange={(yaml) => {
mutation.reset();
setYamlDefinition(yaml);
}}
yaml={yamlDefinition}
/>
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!selectedChannelId || !yamlDefinition.trim() || mutation.isPending
{mutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mutation.error.message}
</p>
) : null}
</div>
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!selectedChannelId ||
!yamlDefinition.trim() ||
mutation.isPending
}
onClick={handleSubmit}
type="button"
>
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
</Button>
</div>
</DialogContent>
</Dialog>
{savedWebhookInfo ? (
<WorkflowWebhookSecretDialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setSavedWebhookInfo(null);
}
onClick={handleSubmit}
type="button"
>
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
</Button>
</div>
</DialogContent>
</Dialog>
}}
open
relayHttpUrl={savedWebhookInfo.relayHttpUrl}
webhookSecret={savedWebhookInfo.webhookSecret}
workflowId={savedWebhookInfo.workflowId}
/>
) : null}
</>
);
}

View File

@@ -2,6 +2,7 @@ import { Code, Plus } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowStepCard } from "./WorkflowStepCard";
@@ -34,6 +35,7 @@ function TriggerConfigFields({
}) {
switch (trigger.on) {
case "message_posted":
case "diff_posted":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-filter">
@@ -49,7 +51,7 @@ function TriggerConfigFields({
value={trigger.filter ?? ""}
/>
<p className="text-xs text-muted-foreground">
Evalexpr filter — leave empty to trigger on all messages.
Evalexpr filter — leave empty to trigger on all matching events.
</p>
</div>
);
@@ -257,6 +259,42 @@ export function WorkflowFormBuilder({
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-description">
Description (optional)
</FieldLabel>
<Textarea
className="min-h-[72px] resize-y text-sm"
disabled={disabled}
id="wf-description"
onChange={(event) =>
updateFormState({
...formState,
description: event.target.value,
})
}
placeholder="What does this workflow do?"
value={formState.description}
/>
</div>
<div className="flex items-center gap-2 rounded-md border border-border/70 px-3 py-2">
<Checkbox
checked={formState.enabled}
disabled={disabled}
id="wf-enabled"
onCheckedChange={(checked) =>
updateFormState({
...formState,
enabled: checked === true,
})
}
/>
<label className="text-sm" htmlFor="wf-enabled">
Workflow is enabled
</label>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-type">Trigger</FieldLabel>
@@ -308,11 +346,13 @@ export function WorkflowFormBuilder({
<div className="space-y-2">
{formState.steps.map((step, index) => (
<WorkflowStepCard
disabled={disabled}
index={index}
key={step.id}
onRemove={() => removeStep(index)}
onUpdate={(updated) => updateStep(index, updated)}
step={step}
triggerType={formState.trigger.on}
/>
))}
</div>

View File

@@ -5,19 +5,52 @@ import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import { ACTION_LABELS, ACTION_TYPES } from "./workflowFormTypes";
import type { ActionType, StepFormState } from "./workflowFormTypes";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
import type {
ActionType,
StepFormState,
TriggerType,
} from "./workflowFormTypes";
// ---------------------------------------------------------------------------
// Step action config fields
// ---------------------------------------------------------------------------
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
switch (action) {
case "send_dm":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: `send_dm` is not executed yet, so runs fail at this
step.
</p>
);
case "set_channel_topic":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: `set_channel_topic` is not executed yet, so runs fail at
this step.
</p>
);
case "request_approval":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: approval gates still stop runs with WF-08; approval
records are not persisted yet.
</p>
);
default:
return null;
}
}
function StepConfigFields({
step,
prefix,
disabled,
triggerType,
onUpdate,
}: {
step: StepFormState;
prefix: string;
disabled?: boolean;
triggerType: TriggerType;
onUpdate: (step: StepFormState) => void;
}) {
switch (step.action) {
@@ -27,6 +60,7 @@ function StepConfigFields({
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-duration`}
onChange={(event) =>
onUpdate({ ...step, duration: event.target.value })
@@ -43,6 +77,7 @@ function StepConfigFields({
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
@@ -57,23 +92,36 @@ function StepConfigFields({
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-channel`}
onChange={(event) =>
onUpdate({ ...step, channel: event.target.value })
}
placeholder="Channel UUID — leave empty for trigger channel"
placeholder="Channel UUID"
value={step.channel ?? ""}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the trigger channel. Webhook runs and manual
Trigger runs need an explicit channel override.
</p>
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
</p>
) : null}
</div>
</div>
);
case "send_dm":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-to`}>To (pubkey)</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-to`}
onChange={(event) =>
onUpdate({ ...step, to: event.target.value })
@@ -86,6 +134,7 @@ function StepConfigFields({
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
@@ -98,11 +147,12 @@ function StepConfigFields({
);
case "call_webhook":
return (
<div className="space-y-2">
<div className="space-y-3">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-url`}>URL</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-url`}
onChange={(event) =>
onUpdate({ ...step, url: event.target.value })
@@ -121,6 +171,7 @@ function StepConfigFields({
Method (optional)
</FieldLabel>
<FormSelect
disabled={disabled}
id={`${prefix}-method`}
onChange={(value) => onUpdate({ ...step, method: value })}
value={step.method ?? "POST"}
@@ -132,10 +183,17 @@ function StepConfigFields({
<option value="DELETE">DELETE</option>
</FormSelect>
</div>
<WorkflowWebhookHeadersEditor
disabled={disabled}
headers={step.headers ?? []}
onChange={(headers) => onUpdate({ ...step, headers })}
stepId={step.id || prefix}
/>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-body`}>Body (optional)</FieldLabel>
<Textarea
className="min-h-[60px] resize-y font-mono text-xs"
disabled={disabled}
id={`${prefix}-body`}
onChange={(event) =>
onUpdate({ ...step, body: event.target.value })
@@ -149,10 +207,12 @@ function StepConfigFields({
case "request_approval":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-from`}>From (approver)</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-from`}
onChange={(event) =>
onUpdate({ ...step, from: event.target.value })
@@ -164,6 +224,7 @@ function StepConfigFields({
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-message`}>Message</FieldLabel>
<Input
disabled={disabled}
id={`${prefix}-message`}
onChange={(event) =>
onUpdate({ ...step, message: event.target.value })
@@ -177,6 +238,7 @@ function StepConfigFields({
Timeout (optional)
</FieldLabel>
<Input
disabled={disabled}
id={`${prefix}-timeout`}
onChange={(event) =>
onUpdate({ ...step, timeout: event.target.value })
@@ -193,6 +255,7 @@ function StepConfigFields({
<FieldLabel htmlFor={`${prefix}-emoji`}>Emoji</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-emoji`}
onChange={(event) =>
onUpdate({ ...step, emoji: event.target.value })
@@ -204,16 +267,20 @@ function StepConfigFields({
);
case "set_channel_topic":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
<Input
id={`${prefix}-topic`}
onChange={(event) =>
onUpdate({ ...step, topic: event.target.value })
}
placeholder="New channel topic"
value={step.topic ?? ""}
/>
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
<Input
disabled={disabled}
id={`${prefix}-topic`}
onChange={(event) =>
onUpdate({ ...step, topic: event.target.value })
}
placeholder="New channel topic"
value={step.topic ?? ""}
/>
</div>
</div>
);
default:
@@ -221,20 +288,20 @@ function StepConfigFields({
}
}
// ---------------------------------------------------------------------------
// Step card
// ---------------------------------------------------------------------------
export function WorkflowStepCard({
index,
disabled,
onRemove,
onUpdate,
step,
triggerType,
}: {
index: number;
disabled?: boolean;
onRemove: () => void;
onUpdate: (step: StepFormState) => void;
step: StepFormState;
triggerType: TriggerType;
}) {
const prefix = `wf-step-${index}`;
@@ -247,6 +314,7 @@ export function WorkflowStepCard({
<Button
aria-label="Remove step"
className="h-7 w-7"
disabled={disabled}
onClick={onRemove}
size="icon"
type="button"
@@ -261,15 +329,34 @@ export function WorkflowStepCard({
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-id`}
onChange={(event) => onUpdate({ ...step, id: event.target.value })}
placeholder="unique_step_id"
value={step.id}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-name`}>
Step name (optional)
</FieldLabel>
<Input
disabled={disabled}
id={`${prefix}-name`}
onChange={(event) =>
onUpdate({ ...step, name: event.target.value })
}
placeholder="Human-friendly label"
value={step.name ?? ""}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-action`}>Action</FieldLabel>
<FormSelect
disabled={disabled}
id={`${prefix}-action`}
onChange={(value) => {
const next = { ...step, action: value as ActionType };
@@ -287,9 +374,46 @@ export function WorkflowStepCard({
))}
</FormSelect>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
Timeout seconds (optional)
</FieldLabel>
<Input
disabled={disabled}
id={`${prefix}-timeout-secs`}
inputMode="numeric"
onChange={(event) =>
onUpdate({ ...step, timeoutSecs: event.target.value })
}
placeholder="e.g. 300"
value={step.timeoutSecs ?? ""}
/>
</div>
</div>
<StepConfigFields onUpdate={onUpdate} prefix={prefix} step={step} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-condition`}>
Run condition (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-condition`}
onChange={(event) =>
onUpdate({ ...step, condition: event.target.value })
}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={step.condition ?? ""}
/>
</div>
<StepConfigFields
disabled={disabled}
onUpdate={onUpdate}
prefix={prefix}
step={step}
triggerType={triggerType}
/>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { FieldLabel } from "./workflowFormPrimitives";
import type { HeaderFormState } from "./workflowFormTypes";
function updateHeaders(
headers: HeaderFormState[],
updater: (headers: HeaderFormState[]) => HeaderFormState[],
) {
return updater(headers);
}
type WorkflowWebhookHeadersEditorProps = {
disabled?: boolean;
headers: HeaderFormState[];
onChange: (headers: HeaderFormState[]) => void;
stepId: string;
};
export function WorkflowWebhookHeadersEditor({
disabled,
headers,
onChange,
stepId,
}: WorkflowWebhookHeadersEditorProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<FieldLabel>Headers (optional)</FieldLabel>
<Button
className="h-7 gap-1 text-xs"
disabled={disabled}
onClick={() =>
onChange(
updateHeaders(headers, (currentHeaders) => [
...currentHeaders,
{
id: `${stepId}_header_${currentHeaders.length + 1}`,
key: "",
value: "",
},
]),
)
}
size="sm"
type="button"
variant="outline"
>
<Plus className="h-3.5 w-3.5" />
Add header
</Button>
</div>
{headers.length === 0 ? (
<p className="text-xs text-muted-foreground">No custom headers.</p>
) : (
<div className="space-y-2">
{headers.map((header, index) => (
<div className="flex items-center gap-2" key={header.id}>
<Input
autoCapitalize="off"
disabled={disabled}
onChange={(event) =>
onChange(
updateHeaders(headers, (currentHeaders) =>
currentHeaders.map((current, currentIndex) =>
currentIndex === index
? { ...current, key: event.target.value }
: current,
),
),
)
}
placeholder="Header name"
value={header.key}
/>
<Input
autoCapitalize="off"
disabled={disabled}
onChange={(event) =>
onChange(
updateHeaders(headers, (currentHeaders) =>
currentHeaders.map((current, currentIndex) =>
currentIndex === index
? { ...current, value: event.target.value }
: current,
),
),
)
}
placeholder="Header value"
value={header.value}
/>
<Button
aria-label="Remove header"
className="h-9 w-9 shrink-0"
disabled={disabled}
onClick={() =>
onChange(
updateHeaders(headers, (currentHeaders) =>
currentHeaders.filter(
(_, currentIndex) => currentIndex !== index,
),
),
)
}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { CopyButton } from "@/features/agents/ui/CopyButton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
type WorkflowWebhookSecretDialogProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
relayHttpUrl: string;
webhookSecret: string;
workflowId: string;
};
export function WorkflowWebhookSecretDialog({
onOpenChange,
open,
relayHttpUrl,
webhookSecret,
workflowId,
}: WorkflowWebhookSecretDialogProps) {
const webhookUrl = `${relayHttpUrl}/api/workflows/${workflowId}/webhook`;
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Webhook Ready</DialogTitle>
<DialogDescription>
This secret is only shown now. If it is lost, re-save the workflow
to generate a new one.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Webhook URL
</p>
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
{webhookUrl}
</pre>
<CopyButton label="Copy URL" value={webhookUrl} />
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
`X-Webhook-Secret`
</p>
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
{webhookSecret}
</pre>
<CopyButton label="Copy Secret" value={webhookSecret} />
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
import type { Workflow } from "@/shared/api/types";
import { TRIGGER_LABELS } from "./workflowFormTypes";
import type { TriggerType } from "./workflowFormTypes";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function getWorkflowEnabled(
definition: Record<string, unknown>,
): boolean {
return definition.enabled !== false;
}
export function getWorkflowDisplayStatus(
workflow: Workflow,
): Workflow["status"] | "disabled" {
if (workflow.status !== "active") {
return workflow.status;
}
return getWorkflowEnabled(workflow.definition) ? workflow.status : "disabled";
}
export function getWorkflowDescription(
definition: Record<string, unknown>,
): string | null {
const description = definition.description;
return typeof description === "string" && description.trim().length > 0
? description.trim()
: null;
}
export function getWorkflowTriggerSummary(
definition: Record<string, unknown>,
): string | null {
const trigger = asRecord(definition.trigger);
if (!trigger) return null;
const on = trigger.on;
if (typeof on !== "string") return null;
const label = TRIGGER_LABELS[on as TriggerType] ?? on;
switch (on) {
case "message_posted":
case "diff_posted":
return typeof trigger.filter === "string" &&
trigger.filter.trim().length > 0
? `${label} · ${trigger.filter}`
: label;
case "reaction_added":
return typeof trigger.emoji === "string" &&
trigger.emoji.trim().length > 0
? `${label} · ${trigger.emoji}`
: label;
case "schedule":
if (typeof trigger.cron === "string" && trigger.cron.trim().length > 0) {
return `${label} · ${trigger.cron}`;
}
if (
typeof trigger.interval === "string" &&
trigger.interval.trim().length > 0
) {
return `${label} · ${trigger.interval}`;
}
return label;
default:
return label;
}
}

View File

@@ -7,6 +7,7 @@ import { stringify as yamlStringify, parse as yamlParse } from "yaml";
export const TRIGGER_TYPES = [
"message_posted",
"reaction_added",
"diff_posted",
"webhook",
"schedule",
] as const;
@@ -31,15 +32,25 @@ export type TriggerConfig = {
interval?: string;
};
export type HeaderFormState = {
id: string;
key: string;
value: string;
};
export type StepFormState = {
id: string;
name?: string;
action: ActionType;
condition?: string;
timeoutSecs?: string;
duration?: string;
text?: string;
channel?: string;
to?: string;
url?: string;
method?: string;
headers?: HeaderFormState[];
body?: string;
emoji?: string;
topic?: string;
@@ -50,12 +61,16 @@ export type StepFormState = {
export type WorkflowFormState = {
name: string;
description: string;
enabled: boolean;
trigger: TriggerConfig;
steps: StepFormState[];
};
export const DEFAULT_FORM_STATE: WorkflowFormState = {
name: "",
description: "",
enabled: true,
trigger: { on: "message_posted" },
steps: [],
};
@@ -63,6 +78,7 @@ export const DEFAULT_FORM_STATE: WorkflowFormState = {
export const TRIGGER_LABELS: Record<TriggerType, string> = {
message_posted: "Message Posted",
reaction_added: "Reaction Added",
diff_posted: "Diff Posted",
webhook: "Webhook",
schedule: "Schedule",
};
@@ -77,12 +93,55 @@ export const ACTION_LABELS: Record<ActionType, string> = {
set_channel_topic: "Set Channel Topic",
};
function toHeaderRows(
headers: unknown,
stepId: string,
): HeaderFormState[] | undefined {
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
return undefined;
}
const rows = Object.entries(headers).map(([key, value], index) => ({
id: `${stepId}_header_${index + 1}`,
key,
value: typeof value === "string" ? value : String(value),
}));
return rows.length > 0 ? rows : undefined;
}
function headersToRecord(
headers: HeaderFormState[] | undefined,
): Record<string, string> | undefined {
if (!headers) return undefined;
const entries = headers
.map(({ key, value }) => [key.trim(), value] as const)
.filter(([key]) => key.length > 0);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries);
}
function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
if (!timeoutSecs) return undefined;
const trimmed = timeoutSecs.trim();
if (!/^\d+$/.test(trimmed)) return undefined;
const parsed = Number(trimmed);
return parsed > 0 ? parsed : undefined;
}
// ---------------------------------------------------------------------------
// Serialization helpers
// ---------------------------------------------------------------------------
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
const fields: Record<string, unknown> = {};
if (step.name?.trim()) fields.name = step.name.trim();
if (step.condition?.trim()) fields.if = step.condition.trim();
const timeoutSecs = parseTimeoutSecs(step.timeoutSecs);
if (timeoutSecs !== undefined) fields.timeout_secs = timeoutSecs;
switch (step.action) {
case "delay":
if (step.duration) fields.duration = step.duration;
@@ -98,6 +157,10 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
case "call_webhook":
if (step.url) fields.url = step.url;
fields.method = step.method || "POST";
{
const headers = headersToRecord(step.headers);
if (headers) fields.headers = headers;
}
if (step.body) fields.body = step.body;
break;
case "request_approval":
@@ -117,7 +180,11 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
export function formStateToYaml(state: WorkflowFormState): string {
const trigger: Record<string, unknown> = { on: state.trigger.on };
if (state.trigger.on === "message_posted" && state.trigger.filter) {
if (
(state.trigger.on === "message_posted" ||
state.trigger.on === "diff_posted") &&
state.trigger.filter
) {
trigger.filter = state.trigger.filter;
}
if (state.trigger.on === "reaction_added" && state.trigger.emoji) {
@@ -134,7 +201,20 @@ export function formStateToYaml(state: WorkflowFormState): string {
...actionFieldsForStep(step),
}));
return yamlStringify({ name: state.name, trigger, steps });
const workflow: Record<string, unknown> = {
name: state.name,
trigger,
steps,
};
if (state.description.trim()) {
workflow.description = state.description.trim();
}
if (!state.enabled) {
workflow.enabled = false;
}
return yamlStringify(workflow);
}
const STEP_ID_PATTERN = /^step_(\d+)$/;
@@ -192,13 +272,23 @@ export function yamlToFormState(
const steps: StepFormState[] = rawSteps.map(
(step: Record<string, unknown>, index: number) => ({
id: (step.id as string) ?? `step_${index + 1}`,
name: step.name as string | undefined,
action: (step.action as ActionType) ?? ACTION_TYPES[0],
condition: step.if as string | undefined,
timeoutSecs:
step.timeout_secs !== undefined
? String(step.timeout_secs)
: undefined,
duration: step.duration as string | undefined,
text: step.text as string | undefined,
channel: step.channel as string | undefined,
to: step.to as string | undefined,
url: step.url as string | undefined,
method: step.method as string | undefined,
headers: toHeaderRows(
step.headers,
(step.id as string) ?? `step_${index + 1}`,
),
body: step.body as string | undefined,
emoji: step.emoji as string | undefined,
topic: step.topic as string | undefined,
@@ -210,7 +300,13 @@ export function yamlToFormState(
return {
ok: true,
state: { name: (parsed.name as string) ?? "", trigger, steps },
state: {
name: (parsed.name as string) ?? "",
description: (parsed.description as string) ?? "",
enabled: parsed.enabled !== false,
trigger,
steps,
},
};
} catch (error) {
return {

View File

@@ -522,6 +522,10 @@ export function getRelayWsUrl(): Promise<string> {
return invokeTauri<string>("get_relay_ws_url");
}
export function getRelayHttpUrl(): Promise<string> {
return invokeTauri<string>("get_relay_http_url");
}
export async function getChannels(): Promise<Channel[]> {
const channels = await invokeTauri<RawChannel[]>("get_channels");
return channels.map(fromRawChannel);

View File

@@ -5,6 +5,7 @@ import type {
Workflow,
WorkflowApproval,
WorkflowRun,
WorkflowSaveResult,
TraceEntry,
} from "@/shared/api/types";
@@ -21,6 +22,10 @@ type RawWorkflow = {
updated_at: number;
};
type RawWorkflowSaveResponse = RawWorkflow & {
webhook_secret?: string | null;
};
type RawTraceEntry = {
step_id: string;
status: string;
@@ -84,6 +89,13 @@ function fromRawWorkflow(raw: RawWorkflow): Workflow {
};
}
function fromRawWorkflowSave(raw: RawWorkflowSaveResponse): WorkflowSaveResult {
return {
workflow: fromRawWorkflow(raw),
webhookSecret: raw.webhook_secret ?? null,
};
}
function fromRawTraceEntry(raw: RawTraceEntry): TraceEntry {
return {
stepId: raw.step_id,
@@ -165,23 +177,23 @@ export async function getWorkflow(workflowId: string): Promise<Workflow> {
export async function createWorkflow(
channelId: string,
yamlDefinition: string,
): Promise<Workflow> {
const raw = await invokeTauri<RawWorkflow>("create_workflow", {
): Promise<WorkflowSaveResult> {
const raw = await invokeTauri<RawWorkflowSaveResponse>("create_workflow", {
channelId,
yamlDefinition,
});
return fromRawWorkflow(raw);
return fromRawWorkflowSave(raw);
}
export async function updateWorkflow(
workflowId: string,
yamlDefinition: string,
): Promise<Workflow> {
const raw = await invokeTauri<RawWorkflow>("update_workflow", {
): Promise<WorkflowSaveResult> {
const raw = await invokeTauri<RawWorkflowSaveResponse>("update_workflow", {
workflowId,
yamlDefinition,
});
return fromRawWorkflow(raw);
return fromRawWorkflowSave(raw);
}
export async function deleteWorkflow(workflowId: string): Promise<void> {

View File

@@ -472,6 +472,7 @@ export type {
WorkflowApprovalStatus,
WorkflowRun,
WorkflowRunStatus,
WorkflowSaveResult,
WorkflowStatus,
TraceEntry,
TriggerWorkflowResponse,

View File

@@ -11,6 +11,11 @@ export type Workflow = {
updatedAt: number;
};
export type WorkflowSaveResult = {
workflow: Workflow;
webhookSecret: string | null;
};
export type WorkflowRunStatus =
| "pending"
| "running"

View File

@@ -1,6 +1,7 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
import { finalizeEvent } from "nostr-tools/pure";
import { parse as yamlParse } from "yaml";
import type { RelayEvent } from "@/shared/api/types";
@@ -1016,6 +1017,16 @@ type MockWorkflow = {
const mockWorkflows: MockWorkflow[] = [];
let mockWorkflowIdCounter = 0;
function parseWorkflowDefinition(
yamlDefinition: string,
): Record<string, unknown> {
const parsed = yamlParse(yamlDefinition);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Workflow definition must be a YAML object");
}
return parsed as Record<string, unknown>;
}
function handleGetChannelWorkflows(args: { channelId: string }) {
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
}
@@ -1032,23 +1043,31 @@ function handleCreateWorkflow(args: {
}) {
mockWorkflowIdCounter += 1;
const now = Math.floor(Date.now() / 1000);
// Parse the name from the YAML definition (simple extraction)
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
const name = nameMatch
? nameMatch[1].trim()
: `workflow_${mockWorkflowIdCounter}`;
const definition = parseWorkflowDefinition(args.yamlDefinition);
const name =
typeof definition.name === "string"
? definition.name
: `workflow_${mockWorkflowIdCounter}`;
const workflow: MockWorkflow = {
id: `mock-wf-${mockWorkflowIdCounter}`,
name,
owner_pubkey: MOCK_IDENTITY_PUBKEY,
channel_id: args.channelId,
definition: { raw: args.yamlDefinition },
definition,
status: "active",
created_at: now,
updated_at: now,
};
mockWorkflows.push(workflow);
return workflow;
const trigger = definition.trigger as Record<string, unknown> | undefined;
return {
...workflow,
webhook_secret:
trigger?.on === "webhook"
? `mock-webhook-secret-${mockWorkflowIdCounter}`
: undefined,
};
}
function handleUpdateWorkflow(args: {
@@ -1057,11 +1076,19 @@ function handleUpdateWorkflow(args: {
}) {
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
if (nameMatch) workflow.name = nameMatch[1].trim();
workflow.definition = { raw: args.yamlDefinition };
const definition = parseWorkflowDefinition(args.yamlDefinition);
if (typeof definition.name === "string") workflow.name = definition.name;
workflow.definition = definition;
workflow.updated_at = Math.floor(Date.now() / 1000);
return workflow;
const trigger = definition.trigger as Record<string, unknown> | undefined;
return {
...workflow,
webhook_secret:
trigger?.on === "webhook"
? `mock-webhook-secret-${workflow.id}`
: undefined,
};
}
function handleDeleteWorkflow(args: { workflowId: string }) {
@@ -3421,6 +3448,8 @@ export function maybeInstallE2eTauriMocks() {
);
case "get_relay_ws_url":
return getRelayWsUrl(activeConfig);
case "get_relay_http_url":
return getRelayHttpUrl(activeConfig);
case "discover_acp_providers":
return handleDiscoverAcpProviders();
case "discover_backend_providers":

View File

@@ -15,24 +15,50 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) {
async function createWorkflow(
page: import("@playwright/test").Page,
name: string,
options?: {
description?: string;
enabled?: boolean;
trigger?: string;
stepCondition?: string;
stepName?: string;
stepTimeoutSecs?: string;
},
) {
await page.getByRole("button", { name: "Create Workflow" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Fill the form — name field
await page.getByLabel("Workflow name").fill(name);
await dialog.getByLabel("Workflow name").fill(name);
if (options?.description) {
await dialog.getByLabel("Description (optional)").fill(options.description);
}
if (options?.enabled === false) {
await dialog.getByLabel("Workflow is enabled").click();
}
if (options?.trigger) {
await dialog.getByLabel("Trigger").selectOption(options.trigger);
}
// Add a step
await page.getByRole("button", { name: "Add step" }).click();
await dialog.getByRole("button", { name: "Add step" }).click();
if (options?.stepName) {
await dialog.getByLabel("Step name (optional)").fill(options.stepName);
}
if (options?.stepCondition) {
await dialog
.getByLabel("Run condition (optional)")
.fill(options.stepCondition);
}
if (options?.stepTimeoutSecs) {
await dialog
.getByLabel("Timeout seconds (optional)")
.fill(options.stepTimeoutSecs);
}
// Submit
await page
.getByRole("dialog")
.getByRole("button", { name: "Create" })
.click();
await dialog.getByRole("button", { name: "Create" }).click();
// Wait for dialog to close
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(
page.getByRole("heading", { name: "Create Workflow" }),
).not.toBeVisible();
}
test("navigates to workflows view and shows empty state", async ({ page }) => {
@@ -54,6 +80,48 @@ test("creates a workflow via the form builder", async ({ page }) => {
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
});
test("captures disabled diff workflows in the list UI", async ({ page }) => {
const workflowName = `diff_workflow_${Date.now()}`;
const description = "Watches diff events for src/ changes";
await navigateToWorkflows(page);
await createWorkflow(page, workflowName, {
description,
enabled: false,
trigger: "diff_posted",
stepName: "Notify reviewers",
stepCondition: 'str_contains(trigger_text, "src/")',
stepTimeoutSecs: "45",
});
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await expect(card).toContainText(workflowName);
await expect(card).toContainText(description);
await expect(card).toContainText("Diff Posted");
await expect(card).toContainText("disabled");
});
test("shows the webhook secret dialog after saving a webhook workflow", async ({
page,
}) => {
const workflowName = `webhook_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName, {
trigger: "webhook",
});
await expect(page.getByText("Webhook Ready")).toBeVisible();
await expect(page.getByRole("button", { name: "Copy URL" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy Secret" })).toBeVisible();
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByText("Webhook Ready")).not.toBeVisible();
});
test("edits an existing workflow", async ({ page }) => {
const originalName = `edit_test_${Date.now()}`;
const updatedName = `${originalName}_updated`;