Compare commits

...

3 Commits

Author SHA1 Message Date
Vikhyath Mondreti
33cf0de31e improvement(grain): make trigger names in line with API since resource type not known 2026-03-14 19:28:56 -07:00
Siddharth Ganesan
aad620c456 fix(mothership): run workflow tools (run from block, run until block) (#3595)
* Fix redis queuing and run

* Fix dynimp
2026-03-14 18:57:55 -07:00
Vikhyath Mondreti
f57294936b fix(embedded): viewport options breaking autolayout (#3596) 2026-03-14 18:57:36 -07:00
17 changed files with 445 additions and 160 deletions

View File

@@ -14,6 +14,7 @@ import {
} from '@/lib/copilot/chat-streaming'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -454,6 +455,30 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
status: string
} | null = null
if (chat.conversationId) {
try {
const [meta, events] = await Promise.all([
getStreamMeta(chat.conversationId),
readStreamEvents(chat.conversationId, 0),
])
streamSnapshot = {
events: events || [],
status: meta?.status || 'unknown',
}
} catch (err) {
logger.warn('Failed to read stream snapshot for chat', {
chatId,
conversationId: chat.conversationId,
error: err instanceof Error ? err.message : String(err),
})
}
}
const transformedChat = {
id: chat.id,
title: chat.title,
@@ -466,6 +491,7 @@ export async function GET(req: NextRequest) {
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
}
logger.info(`Retrieved chat ${chatId}`)

View File

@@ -178,6 +178,7 @@ export function Home({ chatId }: HomeProps = {}) {
const {
messages,
isSending,
isReconnecting,
sendMessage,
stopGeneration,
resolvedChatId,
@@ -330,7 +331,7 @@ export function Home({ chatId }: HomeProps = {}) {
return () => ro.disconnect()
}, [hasMessages])
if (!hasMessages && chatId && isLoadingHistory) {
if (chatId && (isLoadingHistory || isReconnecting)) {
return (
<ChatSkeleton>
<UserInput

View File

@@ -45,6 +45,7 @@ import type {
export interface UseChatReturn {
messages: ChatMessage[]
isSending: boolean
isReconnecting: boolean
error: string | null
resolvedChatId: string | undefined
sendMessage: (
@@ -250,6 +251,7 @@ export function useChat(
const queryClient = useQueryClient()
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
const [isReconnecting, setIsReconnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
const [resources, setResources] = useState<MothershipResource[]>([])
@@ -268,6 +270,10 @@ export function useChat(
}, [messageQueue])
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
const processSSEStreamRef = useRef<
(reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => Promise<void>
>(async () => {})
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
const abortControllerRef = useRef<AbortController | null>(null)
const chatIdRef = useRef<string | undefined>(initialChatId)
@@ -329,6 +335,7 @@ export function useChat(
setMessages([])
setError(null)
setIsSending(false)
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setMessageQueue([])
@@ -346,6 +353,7 @@ export function useChat(
setMessages([])
setError(null)
setIsSending(false)
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setMessageQueue([])
@@ -365,6 +373,95 @@ export function useChat(
ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
}
}
// Kick off stream reconnection immediately if there's an active stream.
// The stream snapshot was fetched in parallel with the chat history (same
// API call), so there's no extra round-trip.
const activeStreamId = chatHistory.activeStreamId
const snapshot = chatHistory.streamSnapshot
if (activeStreamId && !sendingRef.current) {
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
streamIdRef.current = activeStreamId
sendingRef.current = true
setIsReconnecting(true)
const assistantId = crypto.randomUUID()
const reconnect = async () => {
try {
const encoder = new TextEncoder()
const batchEvents = snapshot?.events ?? []
const streamStatus = snapshot?.status ?? ''
if (!snapshot || (batchEvents.length === 0 && streamStatus === 'unknown')) {
// No snapshot available — stream buffer expired. Clean up.
const cid = chatIdRef.current
if (cid) {
fetch('/api/mothership/chat/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId: cid, streamId: activeStreamId, content: '' }),
}).catch(() => {})
}
return
}
setIsSending(true)
setIsReconnecting(false)
const lastEventId =
batchEvents.length > 0 ? batchEvents[batchEvents.length - 1].eventId : 0
const isStreamDone = streamStatus === 'complete' || streamStatus === 'error'
const combinedStream = new ReadableStream<Uint8Array>({
async start(controller) {
if (batchEvents.length > 0) {
const sseText = batchEvents
.map((e) => `data: ${JSON.stringify(e.event)}\n`)
.join('\n')
controller.enqueue(encoder.encode(`${sseText}\n`))
}
if (!isStreamDone) {
try {
const sseRes = await fetch(
`/api/copilot/chat/stream?streamId=${activeStreamId}&from=${lastEventId}`,
{ signal: abortController.signal }
)
if (sseRes.ok && sseRes.body) {
const reader = sseRes.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
controller.enqueue(value)
}
}
} catch (err) {
if (!(err instanceof Error && err.name === 'AbortError')) {
logger.warn('SSE tail failed during reconnect', err)
}
}
}
controller.close()
},
})
await processSSEStreamRef.current(combinedStream.getReader(), assistantId)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
} finally {
setIsReconnecting(false)
if (streamGenRef.current === gen) {
finalizeRef.current()
}
}
}
reconnect()
}
}, [chatHistory, workspaceId])
useEffect(() => {
@@ -405,11 +502,14 @@ export function useChat(
const flush = () => {
streamingBlocksRef.current = [...blocks]
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: runningText, contentBlocks: [...blocks] } : m
)
)
const snapshot = { content: runningText, contentBlocks: [...blocks] }
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
}
return [...prev, { id: assistantId, role: 'assistant' as const, ...snapshot }]
})
}
while (true) {
@@ -662,6 +762,9 @@ export function useChat(
},
[workspaceId, queryClient, addResource, removeResource]
)
useLayoutEffect(() => {
processSSEStreamRef.current = processSSEStream
})
const persistPartialResponse = useCallback(async () => {
const chatId = chatIdRef.current
@@ -750,50 +853,9 @@ export function useChat(
},
[invalidateChatQueries]
)
useEffect(() => {
const activeStreamId = chatHistory?.activeStreamId
if (!activeStreamId || !appliedChatIdRef.current || sendingRef.current) return
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
sendingRef.current = true
setIsSending(true)
const assistantId = crypto.randomUUID()
setMessages((prev) => [
...prev,
{
id: assistantId,
role: 'assistant' as const,
content: '',
contentBlocks: [],
},
])
const reconnect = async () => {
try {
const response = await fetch(`/api/copilot/chat/stream?streamId=${activeStreamId}&from=0`, {
signal: abortController.signal,
})
if (!response.ok || !response.body) return
await processSSEStream(response.body.getReader(), assistantId)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
} finally {
if (streamGenRef.current === gen) {
finalize()
}
}
}
reconnect()
return () => {
abortController.abort()
appliedChatIdRef.current = undefined
}
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
useLayoutEffect(() => {
finalizeRef.current = finalize
})
const sendMessage = useCallback(
async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
@@ -937,7 +999,11 @@ export function useChat(
if (sendingRef.current) {
await persistPartialResponse()
}
const sid = streamIdRef.current
const sid =
streamIdRef.current ||
queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatIdRef.current))
?.activeStreamId ||
undefined
streamGenRef.current++
abortControllerRef.current?.abort()
abortControllerRef.current = null
@@ -1054,6 +1120,7 @@ export function useChat(
return {
messages,
isSending,
isReconnecting,
error,
resolvedChatId,
sendMessage,

View File

@@ -14,11 +14,18 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Dropdown option type - can be a simple string or an object with label, id, and optional icon
* Dropdown option type - can be a simple string or an object with label, id, and optional icon.
* Options with `hidden: true` are excluded from the picker but still resolve for label display,
* so existing workflows that reference them continue to work.
*/
type DropdownOption =
| string
| { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }
| {
label: string
id: string
icon?: React.ComponentType<{ className?: string }>
hidden?: boolean
}
/**
* Props for the Dropdown component
@@ -185,13 +192,12 @@ export const Dropdown = memo(function Dropdown({
return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id }))
}, [fetchedOptions])
const availableOptions = useMemo(() => {
const allOptions = useMemo(() => {
let opts: DropdownOption[] =
fetchOptions && normalizedFetchedOptions.length > 0
? normalizedFetchedOptions
: evaluatedOptions
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
@@ -204,11 +210,12 @@ export const Dropdown = memo(function Dropdown({
return opts
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
/**
* Convert dropdown options to Combobox format
*/
const selectableOptions = useMemo(() => {
return allOptions.filter((opt) => typeof opt === 'string' || !opt.hidden)
}, [allOptions])
const comboboxOptions = useMemo((): ComboboxOption[] => {
return availableOptions.map((opt) => {
return selectableOptions.map((opt) => {
if (typeof opt === 'string') {
return { label: opt.toLowerCase(), value: opt }
}
@@ -218,11 +225,16 @@ export const Dropdown = memo(function Dropdown({
icon: 'icon' in opt ? opt.icon : undefined,
}
})
}, [availableOptions])
}, [selectableOptions])
const optionMap = useMemo(() => {
return new Map(comboboxOptions.map((opt) => [opt.value, opt.label]))
}, [comboboxOptions])
return new Map(
allOptions.map((opt) => {
if (typeof opt === 'string') return [opt, opt.toLowerCase()]
return [opt.id, opt.label.toLowerCase()]
})
)
}, [allOptions])
const defaultOptionValue = useMemo(() => {
if (multiSelect) return undefined

View File

@@ -521,62 +521,69 @@ export async function executeWorkflowWithFullLogging(
const data = line.substring(6).trim()
if (data === '[DONE]') continue
let event: any
try {
const event = JSON.parse(data)
event = JSON.parse(data)
} catch {
continue
}
switch (event.type) {
case 'execution:started': {
setCurrentExecutionId(wfId, event.executionId)
executionIdRef.current = event.executionId || executionId
break
}
case 'block:started':
blockHandlers.onBlockStarted(event.data)
break
case 'block:completed':
blockHandlers.onBlockCompleted(event.data)
break
case 'block:error':
blockHandlers.onBlockError(event.data)
break
case 'block:childWorkflowStarted':
blockHandlers.onBlockChildWorkflowStarted(event.data)
break
case 'execution:completed':
setCurrentExecutionId(wfId, null)
executionResult = {
success: event.data.success,
output: event.data.output,
logs: [],
metadata: {
duration: event.data.duration,
startTime: event.data.startTime,
endTime: event.data.endTime,
},
}
break
case 'execution:cancelled':
setCurrentExecutionId(wfId, null)
executionResult = {
success: false,
output: {},
error: 'Execution was cancelled',
logs: [],
}
break
case 'execution:error':
setCurrentExecutionId(wfId, null)
throw new Error(event.data.error || 'Execution failed')
switch (event.type) {
case 'execution:started': {
setCurrentExecutionId(wfId, event.executionId)
executionIdRef.current = event.executionId || executionId
break
}
} catch (parseError) {
// Skip malformed SSE events
case 'block:started':
blockHandlers.onBlockStarted(event.data)
break
case 'block:completed':
blockHandlers.onBlockCompleted(event.data)
break
case 'block:error':
blockHandlers.onBlockError(event.data)
break
case 'block:childWorkflowStarted':
blockHandlers.onBlockChildWorkflowStarted(event.data)
break
case 'execution:completed':
setCurrentExecutionId(wfId, null)
executionResult = {
success: event.data.success,
output: event.data.output,
logs: [],
metadata: {
duration: event.data.duration,
startTime: event.data.startTime,
endTime: event.data.endTime,
},
}
break
case 'execution:cancelled':
setCurrentExecutionId(wfId, null)
executionResult = {
success: false,
output: {},
error: 'Execution was cancelled',
logs: [],
}
break
case 'execution:error':
setCurrentExecutionId(wfId, null)
executionResult = {
success: false,
output: {},
error: event.data.error || 'Execution failed',
logs: [],
}
break
}
}
}

View File

@@ -207,6 +207,7 @@ const reactFlowStyles = [
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const embeddedFitViewOptions = { padding: 0.15, maxZoom: 0.85, minZoom: 0.35 } as const
const reactFlowProOptions = { hideAttribution: true } as const
/**
@@ -3851,11 +3852,11 @@ const WorkflowContent = React.memo(
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
onInit={(instance) => {
requestAnimationFrame(() => {
instance.fitView(reactFlowFitViewOptions)
instance.fitView(embedded ? embeddedFitViewOptions : reactFlowFitViewOptions)
setIsCanvasReady(true)
})
}}
fitViewOptions={reactFlowFitViewOptions}
fitViewOptions={embedded ? embeddedFitViewOptions : reactFlowFitViewOptions}
minZoom={0.1}
maxZoom={1.3}
panOnScroll

View File

@@ -268,15 +268,17 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
type: 'dropdown',
mode: 'trigger',
options: grainTriggerOptions,
value: () => 'grain_webhook',
value: () => 'grain_item_added',
required: true,
},
...getTrigger('grain_item_added').subBlocks,
...getTrigger('grain_item_updated').subBlocks,
...getTrigger('grain_webhook').subBlocks,
...getTrigger('grain_recording_created').subBlocks,
...getTrigger('grain_recording_updated').subBlocks,
...getTrigger('grain_highlight_created').subBlocks,
...getTrigger('grain_highlight_updated').subBlocks,
...getTrigger('grain_story_created').subBlocks,
...getTrigger('grain_webhook').subBlocks,
],
tools: {
access: [
@@ -447,12 +449,14 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
triggers: {
enabled: true,
available: [
'grain_item_added',
'grain_item_updated',
'grain_webhook',
'grain_recording_created',
'grain_recording_updated',
'grain_highlight_created',
'grain_highlight_updated',
'grain_story_created',
'grain_webhook',
],
},
}

View File

@@ -233,12 +233,14 @@ export interface SubBlockConfig {
id: string
icon?: React.ComponentType<{ className?: string }>
group?: string
hidden?: boolean
}[]
| (() => {
label: string
id: string
icon?: React.ComponentType<{ className?: string }>
group?: string
hidden?: boolean
}[])
min?: number
max?: number

View File

@@ -9,12 +9,18 @@ export interface TaskMetadata {
isUnread: boolean
}
export interface StreamSnapshot {
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
status: string
}
export interface TaskChatHistory {
id: string
title: string | null
messages: TaskStoredMessage[]
activeStreamId: string | null
resources: MothershipResource[]
streamSnapshot?: StreamSnapshot | null
}
export interface TaskStoredToolCall {
@@ -135,6 +141,7 @@ async function fetchChatHistory(chatId: string, signal?: AbortSignal): Promise<T
messages: Array.isArray(chat.messages) ? chat.messages : [],
activeStreamId: chat.conversationId || null,
resources: Array.isArray(chat.resources) ? chat.resources : [],
streamSnapshot: chat.streamSnapshot || null,
}
}

View File

@@ -21,12 +21,6 @@ import type {
import { executeToolAndReport, waitForToolCompletion, waitForToolDecision } from './tool-execution'
const logger = createLogger('CopilotSseHandlers')
const CLIENT_WORKFLOW_TOOLS = new Set([
'run_workflow',
'run_workflow_until_block',
'run_block',
'run_from_block',
])
/**
* Extract the `ui` object from a Go SSE event. The Go backend enriches
@@ -320,17 +314,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
}
if (options.interactive === false) {
if (clientExecutable && CLIENT_WORKFLOW_TOOLS.has(toolName)) {
toolCall.status = 'executing'
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
handleClientCompletion(toolCall, toolCallId, completion)
await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
return
}
if (options.autoExecuteTools !== false) {
fireToolExecution()
}
@@ -580,17 +563,6 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
}
if (options.interactive === false) {
if (clientExecutable && CLIENT_WORKFLOW_TOOLS.has(toolName)) {
toolCall.status = 'executing'
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
handleClientCompletion(toolCall, toolCallId, completion)
await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
return
}
if (options.autoExecuteTools !== false) {
fireToolExecution()
}

View File

@@ -1258,6 +1258,8 @@ export async function createGrainWebhookSubscription(
}
const actionMap: Record<string, Array<'added' | 'updated' | 'removed'>> = {
grain_item_added: ['added'],
grain_item_updated: ['updated'],
grain_recording_created: ['added'],
grain_recording_updated: ['updated'],
grain_highlight_created: ['added'],
@@ -1267,6 +1269,8 @@ export async function createGrainWebhookSubscription(
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_item_added: [],
grain_item_updated: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_added'],

View File

@@ -1,5 +1,7 @@
export { grainHighlightCreatedTrigger } from './highlight_created'
export { grainHighlightUpdatedTrigger } from './highlight_updated'
export { grainItemAddedTrigger } from './item_added'
export { grainItemUpdatedTrigger } from './item_updated'
export { grainRecordingCreatedTrigger } from './recording_created'
export { grainRecordingUpdatedTrigger } from './recording_updated'
export { grainStoryCreatedTrigger } from './story_created'

View File

@@ -0,0 +1,76 @@
import { GrainIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildGenericOutputs, grainV2SetupInstructions } from './utils'
export const grainItemAddedTrigger: TriggerConfig = {
id: 'grain_item_added',
name: 'Grain Item Added',
provider: 'grain',
description: 'Trigger when a new item is added to a Grain view (recording, highlight, or story)',
version: '1.0.0',
icon: GrainIcon,
subBlocks: [
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_added',
},
},
{
id: 'viewId',
title: 'View ID',
type: 'short-input',
placeholder: 'Enter Grain view UUID',
description:
'The view determines which content type fires events (recordings, highlights, or stories).',
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_added',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'grain_item_added',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_added',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: grainV2SetupInstructions('item added'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_added',
},
},
],
outputs: buildGenericOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,76 @@
import { GrainIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildGenericOutputs, grainV2SetupInstructions } from './utils'
export const grainItemUpdatedTrigger: TriggerConfig = {
id: 'grain_item_updated',
name: 'Grain Item Updated',
provider: 'grain',
description: 'Trigger when an item is updated in a Grain view (recording, highlight, or story)',
version: '1.0.0',
icon: GrainIcon,
subBlocks: [
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_updated',
},
},
{
id: 'viewId',
title: 'View ID',
type: 'short-input',
placeholder: 'Enter Grain view UUID',
description:
'The view determines which content type fires events (recordings, highlights, or stories).',
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_updated',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'grain_item_updated',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: grainV2SetupInstructions('item updated'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_item_updated',
},
},
],
outputs: buildGenericOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -1,15 +1,19 @@
import type { TriggerOutput } from '@/triggers/types'
/**
* Shared trigger dropdown options for all Grain triggers
* Trigger dropdown options for Grain triggers.
* New options (Item Added / Item Updated / All Events) correctly scope by view_id only.
* Legacy options are hidden from the picker but still resolve for existing workflows.
*/
export const grainTriggerOptions = [
{ label: 'General Webhook (All Events)', id: 'grain_webhook' },
{ label: 'Recording Created', id: 'grain_recording_created' },
{ label: 'Recording Updated', id: 'grain_recording_updated' },
{ label: 'Highlight Created', id: 'grain_highlight_created' },
{ label: 'Highlight Updated', id: 'grain_highlight_updated' },
{ label: 'Story Created', id: 'grain_story_created' },
{ label: 'Item Added', id: 'grain_item_added' },
{ label: 'Item Updated', id: 'grain_item_updated' },
{ label: 'All Events', id: 'grain_webhook' },
{ label: 'Recording Created', id: 'grain_recording_created', hidden: true },
{ label: 'Recording Updated', id: 'grain_recording_updated', hidden: true },
{ label: 'Highlight Created', id: 'grain_highlight_created', hidden: true },
{ label: 'Highlight Updated', id: 'grain_highlight_updated', hidden: true },
{ label: 'Story Created', id: 'grain_story_created', hidden: true },
]
/**
@@ -32,6 +36,25 @@ export function grainSetupInstructions(eventType: string): string {
.join('')
}
/**
* Setup instructions for the v2 triggers that correctly explain view-based scoping.
*/
export function grainV2SetupInstructions(action: string): string {
const instructions = [
'Enter your Grain API Key (Personal Access Token). You can find or create one in Grain at <strong>Workspace Settings &gt; API</strong> under Integrations on <a href="https://grain.com/app/settings/integrations?tab=api" target="_blank" rel="noopener noreferrer">grain.com</a>.',
`Enter a Grain <strong>view ID</strong>. Each view has a type &mdash; <em>recordings</em>, <em>highlights</em>, or <em>stories</em> &mdash; and only items matching that type will fire the <strong>${action}</strong> event.`,
'To find your view IDs, use the <strong>List Views</strong> operation on this block or call <code>GET /_/public-api/views</code> directly.',
'The webhook is created automatically when you save and will be deleted when you remove this trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join('')
}
/**
* Build output schema for recording events
* Webhook payload structure: { type, user_id, data: { ...recording } }

View File

@@ -1,12 +1,12 @@
import { GrainIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildGenericOutputs, grainSetupInstructions } from './utils'
import { buildGenericOutputs, grainV2SetupInstructions } from './utils'
export const grainWebhookTrigger: TriggerConfig = {
id: 'grain_webhook',
name: 'Grain Webhook',
name: 'Grain All Events',
provider: 'grain',
description: 'Generic webhook trigger for all actions in a selected Grain view',
description: 'Trigger on all actions (added, updated, removed) in a Grain view',
version: '1.0.0',
icon: GrainIcon,
@@ -30,7 +30,8 @@ export const grainWebhookTrigger: TriggerConfig = {
title: 'View ID',
type: 'short-input',
placeholder: 'Enter Grain view UUID',
description: 'Required by Grain to create the webhook subscription.',
description:
'The view determines which content type fires events (recordings, highlights, or stories).',
required: true,
mode: 'trigger',
condition: {
@@ -55,7 +56,7 @@ export const grainWebhookTrigger: TriggerConfig = {
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: grainSetupInstructions('All events'),
defaultValue: grainV2SetupInstructions('all'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',

View File

@@ -89,6 +89,8 @@ import { googleFormsWebhookTrigger } from '@/triggers/googleforms'
import {
grainHighlightCreatedTrigger,
grainHighlightUpdatedTrigger,
grainItemAddedTrigger,
grainItemUpdatedTrigger,
grainRecordingCreatedTrigger,
grainRecordingUpdatedTrigger,
grainStoryCreatedTrigger,
@@ -245,6 +247,8 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
fathom_webhook: fathomWebhookTrigger,
gmail_poller: gmailPollingTrigger,
grain_webhook: grainWebhookTrigger,
grain_item_added: grainItemAddedTrigger,
grain_item_updated: grainItemUpdatedTrigger,
grain_recording_created: grainRecordingCreatedTrigger,
grain_recording_updated: grainRecordingUpdatedTrigger,
grain_highlight_created: grainHighlightCreatedTrigger,