From b09299e0ae006a288ca1e480bdc00da016f92619 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 13 Aug 2026 22:49:22 -0700
Subject: [PATCH] Add workflow message picker
Signed-off-by: Taylor Ho
---
.../features/workflows/ui/MessageIdPicker.tsx | 328 ++++++++++++++++++
.../workflows/ui/WorkflowConditionBuilder.tsx | 13 +
.../workflows/ui/WorkflowFormBuilder.tsx | 5 +
.../workflows/ui/WorkflowStepCard.tsx | 1 +
desktop/tests/e2e/workflows.spec.ts | 38 ++
5 files changed, 385 insertions(+)
create mode 100644 desktop/src/features/workflows/ui/MessageIdPicker.tsx
diff --git a/desktop/src/features/workflows/ui/MessageIdPicker.tsx b/desktop/src/features/workflows/ui/MessageIdPicker.tsx
new file mode 100644
index 000000000..3ddae77b5
--- /dev/null
+++ b/desktop/src/features/workflows/ui/MessageIdPicker.tsx
@@ -0,0 +1,328 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { Check, LoaderCircle, Search } from "lucide-react";
+import * as React from "react";
+
+import { useUsersBatchQuery } from "@/features/profile/hooks";
+import { resolveUserLabel } from "@/features/profile/lib/identity";
+import { useSearchMessagesQuery } from "@/features/search/hooks";
+import { getChannelWindowEvents } from "@/shared/api/channelWindow";
+import { getEventById } from "@/shared/api/tauri";
+import type { ChannelPageCursor, RelayEvent } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+import { Input } from "@/shared/ui/input";
+import { UserAvatar } from "@/shared/ui/UserAvatar";
+import {
+ CHANNEL_MESSAGE_EVENT_KINDS,
+ KIND_STREAM_MESSAGE_DIFF,
+} from "@/shared/constants/kinds";
+import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse";
+
+const PAGE_SIZE = 25;
+const FULL_EVENT_ID = /^[0-9a-f]{64}$/i;
+const PICKABLE_KINDS = new Set([
+ ...CHANNEL_MESSAGE_EVENT_KINDS,
+ KIND_STREAM_MESSAGE_DIFF,
+]);
+
+type MessageCandidate = {
+ id: string;
+ pubkey: string;
+ content: string;
+ createdAt: number;
+};
+
+function eventChannelId(event: RelayEvent): string | null {
+ return event.tags.find((tag) => tag[0] === "h")?.[1] ?? null;
+}
+
+function candidateFromEvent(event: RelayEvent): MessageCandidate {
+ return {
+ id: event.id,
+ pubkey: event.pubkey,
+ content: event.content,
+ createdAt: event.created_at,
+ };
+}
+
+function truncateContent(content: string): string {
+ const normalized = content.trim().replaceAll(/\s+/g, " ");
+ if (!normalized) return "No message body";
+ return normalized.length > 120
+ ? `${normalized.slice(0, 117)}...`
+ : normalized;
+}
+
+function formatTimestamp(unixSeconds: number): string {
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(new Date(unixSeconds * 1_000));
+}
+
+function dedupeCandidates(candidates: MessageCandidate[]): MessageCandidate[] {
+ const seen = new Set();
+ return candidates.filter((candidate) => {
+ const normalized = candidate.id.toLowerCase();
+ if (seen.has(normalized)) return false;
+ seen.add(normalized);
+ return true;
+ });
+}
+
+export function MessageIdPicker({
+ channelId,
+ disabled,
+ id,
+ onChange,
+ value,
+}: {
+ channelId?: string | null;
+ disabled?: boolean;
+ id: string;
+ onChange: (messageId: string) => void;
+ value: string;
+}) {
+ const [query, setQuery] = React.useState("");
+ const normalizedQuery = query.trim().toLowerCase();
+ const pastedEventId = FULL_EVENT_ID.test(normalizedQuery)
+ ? normalizedQuery
+ : null;
+
+ const historyQuery = useInfiniteQuery({
+ enabled: Boolean(channelId),
+ initialPageParam: null as ChannelPageCursor | null,
+ queryKey: ["workflow-message-id-picker", channelId],
+ queryFn: async ({ pageParam }) => {
+ if (!channelId) throw new Error("Choose a channel first.");
+ const events = await getChannelWindowEvents(
+ channelId,
+ pageParam,
+ PAGE_SIZE,
+ );
+ return parseChannelWindowResponse(events, channelId, pageParam);
+ },
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ staleTime: 30_000,
+ });
+
+ const searchQuery = useSearchMessagesQuery(query, {
+ channelId: channelId ?? undefined,
+ enabled: Boolean(channelId) && normalizedQuery.length > 0 && !pastedEventId,
+ limit: 30,
+ minimumQueryLength: 1,
+ });
+
+ const pastedEventQuery = useQuery({
+ enabled: Boolean(channelId && pastedEventId),
+ queryKey: ["workflow-message-id", channelId, pastedEventId],
+ queryFn: () => getEventById(pastedEventId ?? ""),
+ retry: false,
+ staleTime: 60_000,
+ });
+
+ const recentCandidates = React.useMemo(
+ () =>
+ dedupeCandidates(
+ (historyQuery.data?.pages ?? []).flatMap((page) =>
+ page.rows
+ .map(({ event }) => event)
+ .filter((event) => PICKABLE_KINDS.has(event.kind))
+ .map(candidateFromEvent),
+ ),
+ ),
+ [historyQuery.data?.pages],
+ );
+
+ const searchCandidates = React.useMemo(
+ () =>
+ (searchQuery.data?.hits ?? [])
+ .filter((hit) => PICKABLE_KINDS.has(hit.kind))
+ .map((hit) => ({
+ id: hit.eventId,
+ pubkey: hit.pubkey,
+ content: hit.content,
+ createdAt: hit.createdAt,
+ })),
+ [searchQuery.data?.hits],
+ );
+
+ const pastedCandidate = React.useMemo(() => {
+ const event = pastedEventQuery.data;
+ if (
+ !event ||
+ !channelId ||
+ eventChannelId(event) !== channelId ||
+ !PICKABLE_KINDS.has(event.kind)
+ ) {
+ return null;
+ }
+ return candidateFromEvent(event);
+ }, [channelId, pastedEventQuery.data]);
+
+ const displayedCandidates = React.useMemo(() => {
+ if (!normalizedQuery) return recentCandidates;
+ const localMatches = recentCandidates.filter(
+ (candidate) =>
+ candidate.id.toLowerCase().includes(normalizedQuery) ||
+ candidate.content.toLowerCase().includes(normalizedQuery),
+ );
+ return dedupeCandidates([
+ ...(pastedCandidate ? [pastedCandidate] : []),
+ ...localMatches,
+ ...searchCandidates,
+ ]);
+ }, [normalizedQuery, pastedCandidate, recentCandidates, searchCandidates]);
+
+ const profilePubkeys = React.useMemo(
+ () => [...new Set(displayedCandidates.map(({ pubkey }) => pubkey))],
+ [displayedCandidates],
+ );
+ const profilesQuery = useUsersBatchQuery(profilePubkeys);
+ const loading =
+ historyQuery.isLoading ||
+ searchQuery.isFetching ||
+ pastedEventQuery.isFetching;
+ const pastedEventInAnotherChannel =
+ Boolean(pastedEventQuery.data && channelId && pastedEventId) &&
+ !pastedCandidate;
+
+ return (
+
+
+
+ setQuery(event.target.value)}
+ placeholder={
+ channelId
+ ? "Search messages or paste a message ID..."
+ : "Choose a channel first"
+ }
+ value={query}
+ />
+ {loading ? (
+
+ ) : null}
+
+
+ {pastedEventInAnotherChannel ? (
+
+ That message is not available in this channel.
+
+ ) : null}
+
+
{
+ const element = event.currentTarget;
+ const nearBottom =
+ element.scrollHeight - element.scrollTop - element.clientHeight <
+ 80;
+ if (
+ nearBottom &&
+ !normalizedQuery &&
+ historyQuery.hasNextPage &&
+ !historyQuery.isFetchingNextPage
+ ) {
+ void historyQuery.fetchNextPage();
+ }
+ }}
+ >
+ {displayedCandidates.length > 0 ? (
+
+ {displayedCandidates.map((message) => {
+ const profile =
+ profilesQuery.data?.profiles[message.pubkey.toLowerCase()];
+ const author = resolveUserLabel({
+ pubkey: message.pubkey,
+ profiles: profilesQuery.data?.profiles,
+ });
+ const selected = value.toLowerCase() === message.id.toLowerCase();
+ return (
+ {
+ onChange(message.id);
+ setQuery("");
+ }}
+ type="button"
+ >
+
+
+
+
+ {author}
+
+
+ {formatTimestamp(message.createdAt)}
+
+
+
+ {truncateContent(message.content)}
+
+
+ {message.id.slice(0, 12)}...{message.id.slice(-8)}
+
+
+ {selected ? (
+
+ ) : null}
+
+ );
+ })}
+
+ ) : loading ? (
+
+ Loading messages...
+
+ ) : (
+
+ {normalizedQuery ? "No messages found." : "No messages yet."}
+
+ )}
+
+ {!normalizedQuery && historyQuery.hasNextPage ? (
+
void historyQuery.fetchNextPage()}
+ type="button"
+ >
+ {historyQuery.isFetchingNextPage
+ ? "Loading older messages..."
+ : "Load older messages"}
+
+ ) : null}
+
+
+ );
+}
diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx
index dbf0f9db6..14d0c2fc4 100644
--- a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx
@@ -5,6 +5,7 @@ import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { AuthorGridPicker } from "./AuthorGridPicker";
+import { MessageIdPicker } from "./MessageIdPicker";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import {
@@ -86,6 +87,7 @@ function valuePlaceholder(field: string): string {
}
export function WorkflowConditionBuilder({
+ channelId,
channels,
disabled,
idPrefix,
@@ -94,6 +96,7 @@ export function WorkflowConditionBuilder({
triggerType,
value,
}: {
+ channelId?: string | null;
channels: Channel[];
disabled?: boolean;
idPrefix: string;
@@ -337,6 +340,16 @@ export function WorkflowConditionBuilder({
}
value={editor.value}
/>
+ ) : editor.field === "trigger_message_id" ? (
+
+ emitEditor({ ...editor, value: messageId })
+ }
+ value={editor.value}
+ />
) : (
void;
}) {
@@ -65,6 +67,7 @@ function TriggerConfigFields({
return (
) : selectedStep ? (
diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
index ac2ecc5dc..586ac60fb 100644
--- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
@@ -386,6 +386,7 @@ export function WorkflowStepCard({
only this step and continues the run.
{
+ await navigateToWorkflows(page);
+
+ await page.getByRole("button", { name: "Create Workflow" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("combobox", { name: "Channel" }).click();
+ await dialog
+ .getByTestId("channel-combobox-list")
+ .getByRole("button", { name: /general/i })
+ .click();
+ const inspector = dialog.getByTestId("workflow-node-inspector");
+ await inspector.getByLabel("Trigger event").click();
+ await page.getByRole("menuitem", { name: "Reaction Added" }).click();
+ await inspector.getByRole("button", { name: "Message ID" }).click();
+
+ const search = inspector.getByLabel("Search messages or paste a message ID");
+ const messageList = inspector.getByTestId("message-id-picker-list");
+ await expect(search).toBeVisible();
+ await expect(messageList).toHaveCSS("overflow-y", "auto");
+ await expect(
+ messageList.getByRole("button", { name: /Hey team — checking in\./ }),
+ ).toBeVisible();
+
+ await search.fill("custom emoji");
+ const targetMessage = messageList.getByRole("button", {
+ name: /React to me with a custom emoji/,
+ });
+ await expect(targetMessage).toBeVisible();
+ await targetMessage.click();
+
+ await dialog.getByRole("tab", { name: "YAML" }).click();
+ await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
+ /filter: trigger_message_id ==\s+"[0-9a-f]{64}"/,
+ );
+});
+
test("switches an empty workflow between form and YAML modes", async ({
page,
}) => {