From 4f9843803996bea5a97566d16ca14e99470f9785 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 19:56:04 -0700 Subject: [PATCH 01/16] improvement(ui): restore smooth streaming animation, fix follow-up auto-scroll, move CopyCodeButton to emcn --- .../message/components/markdown-renderer.tsx | 3 +- .../components/chat-content/chat-content.tsx | 71 +++++++------ .../home/components/user-input/user-input.tsx | 13 +-- .../app/workspace/[workspaceId]/home/home.tsx | 38 +++---- .../components/code}/copy-code-button.tsx | 16 ++- apps/sim/components/emcn/components/index.ts | 1 + apps/sim/hooks/use-auto-scroll.ts | 7 +- apps/sim/hooks/use-streaming-reveal.ts | 100 ++++++++++++++++++ apps/sim/lib/core/utils/browser-storage.ts | 51 --------- apps/sim/tailwind.config.ts | 1 - 10 files changed, 174 insertions(+), 127 deletions(-) rename apps/sim/components/{ui => emcn/components/code}/copy-code-button.tsx (74%) create mode 100644 apps/sim/hooks/use-streaming-reveal.ts diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index 48d4a144bdb..4ba10a9b6a5 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -1,8 +1,7 @@ import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' -import { Tooltip } from '@/components/emcn' -import { CopyCodeButton } from '@/components/ui/copy-code-button' +import { CopyCodeButton, Tooltip } from '@/components/emcn' import { extractTextContent } from '@/lib/core/utils/react-node-text' export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 72e94545d52..ef9a50cac6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -8,8 +8,7 @@ import 'prismjs/components/prism-bash' import 'prismjs/components/prism-css' import 'prismjs/components/prism-markup' import '@/components/emcn/components/code/code.css' -import { Checkbox, highlight, languages } from '@/components/emcn' -import { CopyCodeButton } from '@/components/ui/copy-code-button' +import { Checkbox, CopyCodeButton, highlight, languages } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { extractTextContent } from '@/lib/core/utils/react-node-text' import { @@ -19,6 +18,7 @@ import { SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { useStreamingReveal } from '@/hooks/use-streaming-reveal' import { useStreamingText } from '@/hooks/use-streaming-text' const LANG_ALIASES: Record = { @@ -148,7 +148,7 @@ const MARKDOWN_COMPONENTS = { {language || 'code'}
@@ -247,30 +247,13 @@ export function ChatContent({ onWorkspaceResourceSelect, smoothStreaming = true, }: ChatContentProps) { - const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) - const previousIsStreamingRef = useRef(isStreaming) - - useEffect(() => { - if (!previousIsStreamingRef.current && isStreaming && content.trim().length > 0) { - hydratedStreamingRef.current = true - } else if (!isStreaming) { - hydratedStreamingRef.current = false - } - previousIsStreamingRef.current = isStreaming - }, [content, isStreaming]) - const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect useEffect(() => { const handler = (e: Event) => { const { type, id, title } = (e as CustomEvent).detail - const RESOURCE_TYPE_MAP: Record = {} - onWorkspaceResourceSelectRef.current?.({ - type: RESOURCE_TYPE_MAP[type] || type, - id, - title: title || id, - }) + onWorkspaceResourceSelectRef.current?.({ type, id, title: title || id }) } window.addEventListener('wsres-click', handler) return () => window.removeEventListener('wsres-click', handler) @@ -281,6 +264,11 @@ export function ChatContent({ const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming]) const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text') + const { committed, incoming, generation } = useStreamingReveal( + rendered, + !hasSpecialContent && isStreaming + ) + if (hasSpecialContent) { type BlockSegment = Exclude< ContentSegment, @@ -348,15 +336,38 @@ export function ChatContent({ } return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')}> - - {rendered} - +
+ {committed && ( +
:first-child]:mt-0', + !incoming && '[&>:last-child]:mb-0' + )} + > + + {committed} + +
+ )} + {incoming && ( +
:first-child]:mt-0 [&>:last-child]:mb-0', + isStreaming && 'animate-stream-fade-in' + )} + > + + {incoming} + +
+ )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 9c54cb87f0c..bae0f084183 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -232,6 +232,7 @@ export function UserInput({ const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending const valueRef = useRef(value) + valueRef.current = value const sttPrefixRef = useRef('') const handleTranscript = useCallback((text: string) => { @@ -271,10 +272,6 @@ export function UserInput({ const isSendingRef = useRef(isSending) isSendingRef.current = isSending - useEffect(() => { - valueRef.current = value - }, [value]) - const textareaRef = mentionMenu.textareaRef const wasSendingRef = useRef(false) const atInsertPosRef = useRef(null) @@ -358,9 +355,7 @@ export function UserInput({ } // Reset after batch so the next non-drop insert uses the cursor position atInsertPosRef.current = null - } catch { - // Invalid JSON — ignore - } + } catch {} textareaRef.current?.focus() return } @@ -372,9 +367,7 @@ export function UserInput({ const resource = JSON.parse(resourceJson) as MothershipResource handleResourceSelect(resource) atInsertPosRef.current = null - } catch { - // Invalid JSON — ignore - } + } catch {} textareaRef.current?.focus() return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 229b161f727..c1474fb6fba 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { Button } from '@/components/emcn' import { PanelLeft } from '@/components/emcn/icons' import { useSession } from '@/lib/auth/auth-client' import { @@ -33,6 +34,7 @@ export function Home({ chatId }: HomeProps = {}) { const { data: session } = useSession() const posthog = usePostHog() const posthogRef = useRef(posthog) + posthogRef.current = posthog const [initialPrompt, setInitialPrompt] = useState('') const hasCheckedLandingStorageRef = useRef(false) const initialViewInputRef = useRef(null) @@ -99,19 +101,12 @@ export function Home({ chatId }: HomeProps = {}) { return } - // const templateId = LandingTemplateStorage.consume() - // if (templateId) { - // logger.info('Retrieved landing page template, redirecting to template detail') - // router.replace(`/workspace/${workspaceId}/templates/${templateId}?use=true`) - // return - // } - const prompt = LandingPromptStorage.consume() if (prompt) { logger.info('Retrieved landing page prompt, populating home input') setInitialPrompt(prompt) } - }, [createWorkflowFromLandingSeed, workspaceId, router]) + }, [createWorkflowFromLandingSeed]) const wasSendingRef = useRef(false) @@ -130,10 +125,6 @@ export function Home({ chatId }: HomeProps = {}) { setIsResourceCollapsed(true) }, [clearWidth]) - const expandResource = useCallback(() => { - setIsResourceCollapsed(false) - }, []) - const handleResourceEvent = useCallback(() => { if (isResourceCollapsedRef.current) { setIsResourceCollapsed(false) @@ -224,10 +215,6 @@ export function Home({ chatId }: HomeProps = {}) { return () => cancelAnimationFrame(id) }, [resources]) - useEffect(() => { - posthogRef.current = posthog - }, [posthog]) - const handleStopGeneration = useCallback(() => { captureEvent(posthogRef.current, 'task_generation_aborted', { workspace_id: workspaceId, @@ -299,9 +286,14 @@ export function Home({ chatId }: HomeProps = {}) { const handleInitialContextRemove = useCallback( (context: ChatContext) => { const resolved = resolveResourceFromContext(context) - if (resolved) removeResource(resolved.type, resolved.id) + if (!resolved) return + removeResource(resolved.type, resolved.id) + const remaining = resources.filter((r) => !(r.type === resolved.type && r.id === resolved.id)) + if (remaining.length === 0) { + collapseResource() + } }, - [resolveResourceFromContext, removeResource] + [resolveResourceFromContext, removeResource, resources, collapseResource] ) const handleWorkspaceResourceSelect = useCallback( @@ -426,14 +418,16 @@ export function Home({ chatId }: HomeProps = {}) { {isResourceCollapsed && (
- +
)}
diff --git a/apps/sim/components/ui/copy-code-button.tsx b/apps/sim/components/emcn/components/code/copy-code-button.tsx similarity index 74% rename from apps/sim/components/ui/copy-code-button.tsx rename to apps/sim/components/emcn/components/code/copy-code-button.tsx index 87f17764cf0..5ef81dfb8a3 100644 --- a/apps/sim/components/ui/copy-code-button.tsx +++ b/apps/sim/components/emcn/components/code/copy-code-button.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { Check, Copy } from '@/components/emcn' +import { Button, Check, Copy } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' interface CopyCodeButtonProps { @@ -19,9 +19,7 @@ export function CopyCodeButton({ code, className }: CopyCodeButtonProps) { setCopied(true) if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => setCopied(false), 2000) - } catch { - // Clipboard write can fail when document lacks focus or permission is denied - } + } catch {} }, [code]) useEffect( @@ -32,15 +30,13 @@ export function CopyCodeButton({ code, className }: CopyCodeButtonProps) { ) return ( - + ) } diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index c9148935ac0..c001fbb77f5 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -32,6 +32,7 @@ export { highlight, languages, } from './code/code' +export { CopyCodeButton } from './code/copy-code-button' export { Combobox, type ComboboxOption, diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index e8829af5f5c..11423c16dc9 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -117,7 +117,12 @@ export function useAutoScroll( el.removeEventListener('scroll', onScroll) observer.disconnect() cancelAnimationFrame(rafIdRef.current) - if (stickyRef.current) scrollToBottom() + if (stickyRef.current) { + scrollToBottom() + const settled = new MutationObserver(() => scrollToBottom()) + settled.observe(el, { childList: true, subtree: true }) + setTimeout(() => settled.disconnect(), 300) + } } }, [isStreaming, scrollToBottom]) diff --git a/apps/sim/hooks/use-streaming-reveal.ts b/apps/sim/hooks/use-streaming-reveal.ts new file mode 100644 index 00000000000..6c26582b839 --- /dev/null +++ b/apps/sim/hooks/use-streaming-reveal.ts @@ -0,0 +1,100 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +/** + * Finds the last paragraph break (`\n\n`) that is not inside a fenced code + * block. Returns the index immediately after the break — the start of the + * next paragraph — so the caller can slice cleanly. + */ +function findSafeSplitPoint(content: string): number { + let inCodeBlock = false + let lastSafeBreak = 0 + let i = 0 + + while (i < content.length) { + if (content[i] === '`' && content[i + 1] === '`' && content[i + 2] === '`') { + inCodeBlock = !inCodeBlock + i += 3 + continue + } + + if (!inCodeBlock && content[i] === '\n' && content[i + 1] === '\n') { + lastSafeBreak = i + 2 + i += 2 + continue + } + + i++ + } + + return lastSafeBreak +} + +interface StreamingRevealResult { + /** Stable head — paragraphs that have fully arrived. Ideal for memoisation. */ + committed: string + /** Active tail — the paragraph currently being streamed. */ + incoming: string + /** Increments each time committed advances; use to reset per-paragraph animation state. */ + generation: number +} + +/** + * Splits streaming markdown into a stable *committed* head and an active + * *incoming* tail. The split always occurs at a paragraph boundary (`\n\n`) + * that is outside fenced code blocks, so both halves are valid markdown. + * + * The split is preserved after streaming ends to prevent layout shifts. It + * only resets when content clears (new message). + */ +export function useStreamingReveal(content: string, isStreaming: boolean): StreamingRevealResult { + const [committedEnd, setCommittedEnd] = useState(0) + const [generation, setGeneration] = useState(0) + const prevSplitRef = useRef(0) + + useEffect(() => { + if (content.length === 0) { + prevSplitRef.current = 0 + setCommittedEnd(0) + return + } + + if (!isStreaming) return + + const splitPoint = findSafeSplitPoint(content) + if (splitPoint > prevSplitRef.current) { + prevSplitRef.current = splitPoint + setCommittedEnd(splitPoint) + setGeneration((g) => g + 1) + } + }, [content, isStreaming]) + + if (!isStreaming) { + const preservedSplit = prevSplitRef.current + + if (preservedSplit > 0 && preservedSplit < content.length) { + return { + committed: content.slice(0, preservedSplit), + incoming: content.slice(preservedSplit), + generation, + } + } + + return { committed: content, incoming: '', generation } + } + + if (committedEnd > 0 && committedEnd < content.length) { + return { + committed: content.slice(0, committedEnd), + incoming: content.slice(committedEnd), + generation, + } + } + + if (committedEnd === 0 && content.length > 0) { + return { committed: '', incoming: content, generation } + } + + return { committed: content, incoming: '', generation } +} diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index 209416b3949..fddebe7ebc8 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -101,7 +101,6 @@ export class BrowserStorage { export const STORAGE_KEYS = { LANDING_PAGE_PROMPT: 'sim_landing_page_prompt', - LANDING_PAGE_TEMPLATE: 'sim_landing_page_template', LANDING_PAGE_WORKFLOW_SEED: 'sim_landing_page_workflow_seed', WORKSPACE_RECENCY: 'sim_workspace_recency', } as const @@ -248,56 +247,6 @@ export class LandingPromptStorage { } } -/** - * Specialized utility for managing a template selection from the landing page. - * Stores the marketplace template ID so it can be consumed after signup. - */ -export class LandingTemplateStorage { - private static readonly KEY = STORAGE_KEYS.LANDING_PAGE_TEMPLATE - - /** - * Store a template ID selected on the landing page - * @param templateId - The marketplace template UUID - */ - static store(templateId: string): boolean { - if (!templateId || templateId.trim().length === 0) { - return false - } - - return BrowserStorage.setItem(LandingTemplateStorage.KEY, { - templateId: templateId.trim(), - timestamp: Date.now(), - }) - } - - /** - * Retrieve and consume the stored template ID - * @param maxAge - Maximum age in milliseconds (default: 24 hours) - */ - static consume(maxAge: number = 24 * 60 * 60 * 1000): string | null { - const data = BrowserStorage.getItem<{ templateId: string; timestamp: number } | null>( - LandingTemplateStorage.KEY, - null - ) - - if (!data || !data.templateId || !data.timestamp) { - return null - } - - if (Date.now() - data.timestamp > maxAge) { - LandingTemplateStorage.clear() - return null - } - - LandingTemplateStorage.clear() - return data.templateId - } - - static clear(): boolean { - return BrowserStorage.removeItem(LandingTemplateStorage.KEY) - } -} - export interface LandingWorkflowSeed { templateId: string workflowName: string diff --git a/apps/sim/tailwind.config.ts b/apps/sim/tailwind.config.ts index 315e6ed2ff3..9eddd77011b 100644 --- a/apps/sim/tailwind.config.ts +++ b/apps/sim/tailwind.config.ts @@ -223,7 +223,6 @@ export default { 'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite', 'ring-pulse': 'ring-pulse 1.5s ease-in-out infinite', 'stream-fade-in': 'stream-fade-in 300ms ease-out forwards', - 'stream-fade-in-delayed': 'stream-fade-in 300ms ease-out 1.5s forwards', 'thinking-block': 'thinking-block 1.6s ease-in-out infinite', 'slide-in-right': 'slide-in-right 350ms ease-out forwards', 'slide-in-bottom': 'slide-in-bottom 400ms cubic-bezier(0.16, 1, 0.3, 1)', From aba72b8873d76626161d91aa1f2503fbeb1dc6bd Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:16:08 -0700 Subject: [PATCH 02/16] fix(ui): restore delayed animation, handle tilde fences, fix follow-up scroll root cause --- .../home/components/mothership-chat/mothership-chat.tsx | 2 +- apps/sim/hooks/use-auto-scroll.ts | 7 +------ apps/sim/hooks/use-streaming-reveal.ts | 5 ++++- apps/sim/tailwind.config.ts | 1 + 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 1030d3951ad..fb94ac5a759 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -176,7 +176,7 @@ export function MothershipChat({ blocks={msg.contentBlocks || []} fallbackContent={msg.content} isStreaming={isThisStreaming} - onOptionSelect={isLastMessage && !isStreamActive ? onSubmit : undefined} + onOptionSelect={isLastMessage ? onSubmit : undefined} onWorkspaceResourceSelect={onWorkspaceResourceSelect} /> {!isThisStreaming && (msg.content || msg.contentBlocks?.length) && ( diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index 11423c16dc9..e8829af5f5c 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -117,12 +117,7 @@ export function useAutoScroll( el.removeEventListener('scroll', onScroll) observer.disconnect() cancelAnimationFrame(rafIdRef.current) - if (stickyRef.current) { - scrollToBottom() - const settled = new MutationObserver(() => scrollToBottom()) - settled.observe(el, { childList: true, subtree: true }) - setTimeout(() => settled.disconnect(), 300) - } + if (stickyRef.current) scrollToBottom() } }, [isStreaming, scrollToBottom]) diff --git a/apps/sim/hooks/use-streaming-reveal.ts b/apps/sim/hooks/use-streaming-reveal.ts index 6c26582b839..ad6792173b3 100644 --- a/apps/sim/hooks/use-streaming-reveal.ts +++ b/apps/sim/hooks/use-streaming-reveal.ts @@ -13,7 +13,10 @@ function findSafeSplitPoint(content: string): number { let i = 0 while (i < content.length) { - if (content[i] === '`' && content[i + 1] === '`' && content[i + 2] === '`') { + const isBacktickFence = content[i] === '`' && content[i + 1] === '`' && content[i + 2] === '`' + const isTildeFence = content[i] === '~' && content[i + 1] === '~' && content[i + 2] === '~' + + if (isBacktickFence || isTildeFence) { inCodeBlock = !inCodeBlock i += 3 continue diff --git a/apps/sim/tailwind.config.ts b/apps/sim/tailwind.config.ts index 9eddd77011b..315e6ed2ff3 100644 --- a/apps/sim/tailwind.config.ts +++ b/apps/sim/tailwind.config.ts @@ -223,6 +223,7 @@ export default { 'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite', 'ring-pulse': 'ring-pulse 1.5s ease-in-out infinite', 'stream-fade-in': 'stream-fade-in 300ms ease-out forwards', + 'stream-fade-in-delayed': 'stream-fade-in 300ms ease-out 1.5s forwards', 'thinking-block': 'thinking-block 1.6s ease-in-out infinite', 'slide-in-right': 'slide-in-right 350ms ease-out forwards', 'slide-in-bottom': 'slide-in-bottom 400ms cubic-bezier(0.16, 1, 0.3, 1)', From 77f2b27c5748b9843554d62a3102aea12b7fcf83 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:23:11 -0700 Subject: [PATCH 03/16] fix(ui): extract useStreamingReveal to followup, keep cleanup changes --- .../components/chat-content/chat-content.tsx | 46 ++------ apps/sim/hooks/use-streaming-reveal.ts | 103 ------------------ 2 files changed, 8 insertions(+), 141 deletions(-) delete mode 100644 apps/sim/hooks/use-streaming-reveal.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index ef9a50cac6c..624df13e27d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -18,7 +18,6 @@ import { SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' -import { useStreamingReveal } from '@/hooks/use-streaming-reveal' import { useStreamingText } from '@/hooks/use-streaming-text' const LANG_ALIASES: Record = { @@ -264,11 +263,6 @@ export function ChatContent({ const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming]) const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text') - const { committed, incoming, generation } = useStreamingReveal( - rendered, - !hasSpecialContent && isStreaming - ) - if (hasSpecialContent) { type BlockSegment = Exclude< ContentSegment, @@ -336,38 +330,14 @@ export function ChatContent({ } return ( -
- {committed && ( -
:first-child]:mt-0', - !incoming && '[&>:last-child]:mb-0' - )} - > - - {committed} - -
- )} - {incoming && ( -
:first-child]:mt-0 [&>:last-child]:mb-0', - isStreaming && 'animate-stream-fade-in' - )} - > - - {incoming} - -
- )} +
:first-child]:mt-0 [&>:last-child]:mb-0')}> + + {rendered} +
) } diff --git a/apps/sim/hooks/use-streaming-reveal.ts b/apps/sim/hooks/use-streaming-reveal.ts deleted file mode 100644 index ad6792173b3..00000000000 --- a/apps/sim/hooks/use-streaming-reveal.ts +++ /dev/null @@ -1,103 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from 'react' - -/** - * Finds the last paragraph break (`\n\n`) that is not inside a fenced code - * block. Returns the index immediately after the break — the start of the - * next paragraph — so the caller can slice cleanly. - */ -function findSafeSplitPoint(content: string): number { - let inCodeBlock = false - let lastSafeBreak = 0 - let i = 0 - - while (i < content.length) { - const isBacktickFence = content[i] === '`' && content[i + 1] === '`' && content[i + 2] === '`' - const isTildeFence = content[i] === '~' && content[i + 1] === '~' && content[i + 2] === '~' - - if (isBacktickFence || isTildeFence) { - inCodeBlock = !inCodeBlock - i += 3 - continue - } - - if (!inCodeBlock && content[i] === '\n' && content[i + 1] === '\n') { - lastSafeBreak = i + 2 - i += 2 - continue - } - - i++ - } - - return lastSafeBreak -} - -interface StreamingRevealResult { - /** Stable head — paragraphs that have fully arrived. Ideal for memoisation. */ - committed: string - /** Active tail — the paragraph currently being streamed. */ - incoming: string - /** Increments each time committed advances; use to reset per-paragraph animation state. */ - generation: number -} - -/** - * Splits streaming markdown into a stable *committed* head and an active - * *incoming* tail. The split always occurs at a paragraph boundary (`\n\n`) - * that is outside fenced code blocks, so both halves are valid markdown. - * - * The split is preserved after streaming ends to prevent layout shifts. It - * only resets when content clears (new message). - */ -export function useStreamingReveal(content: string, isStreaming: boolean): StreamingRevealResult { - const [committedEnd, setCommittedEnd] = useState(0) - const [generation, setGeneration] = useState(0) - const prevSplitRef = useRef(0) - - useEffect(() => { - if (content.length === 0) { - prevSplitRef.current = 0 - setCommittedEnd(0) - return - } - - if (!isStreaming) return - - const splitPoint = findSafeSplitPoint(content) - if (splitPoint > prevSplitRef.current) { - prevSplitRef.current = splitPoint - setCommittedEnd(splitPoint) - setGeneration((g) => g + 1) - } - }, [content, isStreaming]) - - if (!isStreaming) { - const preservedSplit = prevSplitRef.current - - if (preservedSplit > 0 && preservedSplit < content.length) { - return { - committed: content.slice(0, preservedSplit), - incoming: content.slice(preservedSplit), - generation, - } - } - - return { committed: content, incoming: '', generation } - } - - if (committedEnd > 0 && committedEnd < content.length) { - return { - committed: content.slice(0, committedEnd), - incoming: content.slice(committedEnd), - generation, - } - } - - if (committedEnd === 0 && content.length > 0) { - return { committed: '', incoming: content, generation } - } - - return { committed: content, incoming: '', generation } -} From c68263f7af3805a3b90576ff3f8560e0b3e111f2 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:28:04 -0700 Subject: [PATCH 04/16] fix(ui): restore hydratedStreamingRef for reconnect path order-of-ops --- .../message-content/components/chat-content/chat-content.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 624df13e27d..7cca4bee690 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -246,6 +246,8 @@ export function ChatContent({ onWorkspaceResourceSelect, smoothStreaming = true, }: ChatContentProps) { + const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) + const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect @@ -334,6 +336,7 @@ export function ChatContent({ {rendered} From 0ecd6bdd880767abffa5a6eabea8e27f8b36bc12 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:29:07 -0700 Subject: [PATCH 05/16] fix(ui): restore full hydratedStreamingRef effect for reconnect path --- .../components/chat-content/chat-content.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 7cca4bee690..bc5a9505841 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -247,6 +247,16 @@ export function ChatContent({ smoothStreaming = true, }: ChatContentProps) { const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) + const previousIsStreamingRef = useRef(isStreaming) + + useEffect(() => { + if (!previousIsStreamingRef.current && isStreaming && content.trim().length > 0) { + hydratedStreamingRef.current = true + } else if (!isStreaming) { + hydratedStreamingRef.current = false + } + previousIsStreamingRef.current = isStreaming + }, [content, isStreaming]) const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect From 335c2952f112bf7d2bf7c5ff30d54e0089ada4bf Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:44:41 -0700 Subject: [PATCH 06/16] fix(ui): use hover-hover prefix on CopyCodeButton callers to correctly override ghost variant --- .../chat/components/message/components/markdown-renderer.tsx | 2 +- .../message-content/components/chat-content/chat-content.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index 4ba10a9b6a5..12254f18dd5 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -99,7 +99,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index bc5a9505841..46091cd9ce3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -147,7 +147,7 @@ const MARKDOWN_COMPONENTS = {
           {language || 'code'}
           
         
From b6ab71d54a7b13909e568f593da59fc38cdd682b Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:46:42 -0700 Subject: [PATCH 07/16] fix(logs): remove destructive color from cancel execution menu item --- .../components/log-row-context-menu/log-row-context-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index db260f21ab6..ceb47cd8f1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -74,7 +74,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ > {isRunning && ( <> - + Cancel Execution From 03f6e74ed0a73c7567203aff00d5b78fd7b55b0d Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:48:36 -0700 Subject: [PATCH 08/16] feat(logs): optimistic cancelling status on cancel execution --- .../app/workspace/[workspaceId]/logs/utils.ts | 5 +++- apps/sim/hooks/queries/logs.ts | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 6ff5d23e011..1f77a590435 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -29,7 +29,7 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [ export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow' export const DELETED_WORKFLOW_COLOR = 'var(--text-tertiary)' -export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' +export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' | 'cancelling' /** * Maps raw status string to LogStatus for display. @@ -42,6 +42,8 @@ export function getDisplayStatus(status: string | null | undefined): LogStatus { return 'running' case 'pending': return 'pending' + case 'cancelling': + return 'cancelling' case 'cancelled': return 'cancelled' case 'failed': @@ -58,6 +60,7 @@ export const STATUS_CONFIG: Record< error: { variant: 'red', label: 'Error', color: 'var(--text-error)' }, pending: { variant: 'amber', label: 'Pending', color: '#f59e0b' }, running: { variant: 'amber', label: 'Running', color: '#f59e0b' }, + cancelling: { variant: 'amber', label: 'Cancelling...', color: '#f59e0b' }, cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316' }, info: { variant: 'gray', label: 'Info', color: 'var(--terminal-status-info-color)' }, } diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 75ad18c1e50..835b5d7f97c 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -1,4 +1,5 @@ import { + type InfiniteData, keepPreviousData, type QueryClient, useInfiniteQuery, @@ -276,6 +277,8 @@ export function useExecutionSnapshot(executionId: string | undefined) { }) } +type LogsPage = { logs: WorkflowLog[]; hasMore: boolean; nextPage: number | undefined } + export function useCancelExecution() { const queryClient = useQueryClient() return useMutation({ @@ -294,6 +297,33 @@ export function useCancelExecution() { if (!data.success) throw new Error('Failed to cancel execution') return data }, + onMutate: async ({ executionId }) => { + await queryClient.cancelQueries({ queryKey: logKeys.lists() }) + + const previousQueries = queryClient.getQueriesData>({ + queryKey: logKeys.lists(), + }) + + queryClient.setQueriesData>({ queryKey: logKeys.lists() }, (old) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + logs: page.logs.map((log) => + log.executionId === executionId ? { ...log, status: 'cancelling' } : log + ), + })), + } + }) + + return { previousQueries } + }, + onError: (_err, _variables, context) => { + for (const [queryKey, data] of context?.previousQueries ?? []) { + queryClient.setQueryData(queryKey, data) + } + }, onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) From 316b6ac1d9d012f37cf0303d10b3fb481a4d6cf9 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 20:50:11 -0700 Subject: [PATCH 09/16] feat(logs): allow cancellation of pending (paused) executions --- .../components/log-row-context-menu/log-row-context-menu.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index ceb47cd8f1d..2daf13aca1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -48,7 +48,8 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ }: LogRowContextMenuProps) { const hasExecutionId = Boolean(log?.executionId) const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId) - const isRunning = log?.status === 'running' && hasExecutionId && hasWorkflow + const isCancellable = + (log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow return ( !open && onClose()} modal={false}> @@ -72,7 +73,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - {isRunning && ( + {isCancellable && ( <> From 8b920dc48df5eb620353429d5b15953fa2d94292 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 21:01:33 -0700 Subject: [PATCH 10/16] fix(hitl): cancel paused executions directly in DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paused HITL executions are idle in the DB — they don't poll Redis or run in-process, so the existing cancel signals had no effect. The DB status stayed 'pending', causing the optimistic 'cancelling' update to revert on refetch. - Add PauseResumeManager.cancelPausedExecution: atomically sets paused_executions.status and workflow_execution_logs.status to 'cancelled' inside a FOR UPDATE transaction - Guard enqueueOrStartResume against resuming a cancelled execution - Include pausedCancelled in the cancel route success check --- .../executions/[executionId]/cancel/route.ts | 11 ++++- .../executor/human-in-the-loop-manager.ts | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 4fb045c5816..62203dc5a99 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -4,6 +4,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { markExecutionCancelled } from '@/lib/execution/cancellation' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { captureServerEvent } from '@/lib/posthog/server' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('CancelExecutionAPI') @@ -49,11 +50,14 @@ export async function POST( const cancellation = await markExecutionCancelled(executionId) const locallyAborted = abortManualExecution(executionId) + const pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId) if (cancellation.durablyRecorded) { logger.info('Execution marked as cancelled in Redis', { executionId }) } else if (locallyAborted) { logger.info('Execution cancelled via local in-process fallback', { executionId }) + } else if (pausedCancelled) { + logger.info('Paused execution cancelled directly in database', { executionId }) } else { logger.warn('Execution cancellation was not durably recorded', { executionId, @@ -61,7 +65,9 @@ export async function POST( }) } - if (cancellation.durablyRecorded || locallyAborted) { + const success = cancellation.durablyRecorded || locallyAborted || pausedCancelled + + if (success) { const workspaceId = workflowAuthorization.workflow?.workspaceId captureServerEvent( auth.userId, @@ -72,11 +78,12 @@ export async function POST( } return NextResponse.json({ - success: cancellation.durablyRecorded || locallyAborted, + success, executionId, redisAvailable: cancellation.reason !== 'redis_unavailable', durablyRecorded: cancellation.durablyRecorded, locallyAborted, + pausedCancelled, reason: cancellation.reason, }) } catch (error: any) { diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 7a1c8cfb4f4..0f9bab9a318 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -193,6 +193,10 @@ export class PauseResumeManager { throw new Error('Paused execution not found or already resumed') } + if (pausedExecution.status === 'cancelled') { + throw new Error('Execution has been cancelled') + } + const pausePoints = pausedExecution.pausePoints as Record const pausePoint = pausePoints?.[contextId] if (!pausePoint) { @@ -1253,6 +1257,43 @@ export class PauseResumeManager { }) } + /** + * Cancels a paused execution by updating both the paused execution record and the + * workflow execution log status to 'cancelled'. Returns true if a paused execution + * was found and cancelled, false if no paused execution exists for this executionId. + */ + static async cancelPausedExecution(executionId: string): Promise { + const now = new Date() + + return await db.transaction(async (tx) => { + const pausedExecution = await tx + .select({ id: pausedExecutions.id }) + .from(pausedExecutions) + .where( + and(eq(pausedExecutions.executionId, executionId), eq(pausedExecutions.status, 'paused')) + ) + .for('update') + .limit(1) + .then((rows) => rows[0]) + + if (!pausedExecution) { + return false + } + + await tx + .update(pausedExecutions) + .set({ status: 'cancelled', updatedAt: now }) + .where(eq(pausedExecutions.id, pausedExecution.id)) + + await tx + .update(workflowExecutionLogs) + .set({ status: 'cancelled' }) + .where(eq(workflowExecutionLogs.executionId, executionId)) + + return true + }) + } + static async listPausedExecutions(options: { workflowId: string status?: string | string[] From 394be59a3a598e3948e85ed771c1bf40cad52a07 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 21:02:05 -0700 Subject: [PATCH 11/16] upgrade turbo --- bun.lock | 17 +++++++++-------- package.json | 2 +- turbo.json | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 8c8d6491ef4..d5a11f32ad9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -13,7 +14,7 @@ "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", "lint-staged": "16.0.0", - "turbo": "2.9.5", + "turbo": "2.9.6", }, }, "apps/docs": { @@ -1518,17 +1519,17 @@ "@trigger.dev/sdk": ["@trigger.dev/sdk@4.4.3", "", { "dependencies": { "@opentelemetry/api": "1.9.0", "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/core": "4.4.3", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "uuid": "^9.0.0", "ws": "^8.11.0" }, "peerDependencies": { "ai": "^4.2.0 || ^5.0.0 || ^6.0.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai"] }, "sha512-ghJkak+PTBJJ9HiHMcnahJmzjsgCzYiIHu5Qj5R7I9q5LS6i7mkx169rB/tOE9HLadd4HSu3yYA5DrH4wXhZuw=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vkF/9F/l3aWd4bHxTui5Hh0F5xrTZ4e3rbBsc57zA6O8gNbmHN3B6eZ5psAIP2CnJRZ8ZxRjV3WZHeNXMXkPBw=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], - "@turbo/linux-64": ["@turbo/linux-64@2.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-z/Get5NUaUxm5HSGFqVMICDRjFNsCUhSc4wnFa/PP1QD0NXCjr7bu9a2EM6md/KMCBW0Qe393Ac+UM7/ryDDTw=="], + "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-jyBifaNoI5/NheyswomiZXJvjdAdvT7hDRYzQ4meP0DKGvpXUjnqsD+4/J2YSDQ34OHxFkL30FnSCUIVOh2PHw=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], - "@turbo/windows-64": ["@turbo/windows-64@2.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ph24K5uPtvo7UfuyDXnBiB/8XvrO+RQWbbw5zkA/bVNoy9HDiNoIJJj3s62MxT9tjEb6DnPje5PXSz1UR7QAyg=="], + "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-6c5RccT/+iR39SdT1G5HyZaD2n57W77o+l0TTfxG/cVlhV94Acyg2gTQW7zUOhW1BeQpBjHzu9x8yVBZwrHh7g=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], @@ -3766,7 +3767,7 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.9.5", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.5", "@turbo/darwin-arm64": "2.9.5", "@turbo/linux-64": "2.9.5", "@turbo/linux-arm64": "2.9.5", "@turbo/windows-64": "2.9.5", "@turbo/windows-arm64": "2.9.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-JXNkRe6H6MjSlk5UQRTjyoKX5YN2zlc2632xcSlSFBao5yvbMWTpv9SNolOZlZmUlcDOHuszPLItbKrvcXnnZA=="], + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], diff --git a/package.json b/package.json index 3b38f2fef10..d78396fbb5c 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", "lint-staged": "16.0.0", - "turbo": "2.9.5" + "turbo": "2.9.6" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,scss}": [ diff --git a/turbo.json b/turbo.json index 4469e31e0ae..ebca501d47d 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://v2-9-5.turborepo.dev/schema.json", + "$schema": "https://v2-9-6.turborepo.dev/schema.json", "envMode": "loose", "tasks": { "transit": { From 42d17d6007adb01716a31bde86ccb9f40f49ce8b Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 21:06:48 -0700 Subject: [PATCH 12/16] test(hitl): update cancel route tests for paused execution cancellation - Mock PauseResumeManager.cancelPausedExecution to prevent DB calls - Add pausedCancelled to all expected response objects - Add test for HITL paused execution cancellation path - Add missing auth/authz tests - Switch to vi.hoisted pattern for all mocks --- .../[executionId]/cancel/route.test.ts | 119 ++++++++++++------ 1 file changed, 80 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index e3f373675a0..da4401afbbc 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -5,13 +5,18 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mockCheckHybridAuth = vi.fn() -const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() -const mockMarkExecutionCancelled = vi.fn() -const mockAbortManualExecution = vi.fn() - -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +const { + mockCheckHybridAuth, + mockAuthorizeWorkflowByWorkspacePermission, + mockMarkExecutionCancelled, + mockAbortManualExecution, + mockCancelPausedExecution, +} = vi.hoisted(() => ({ + mockCheckHybridAuth: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockMarkExecutionCancelled: vi.fn(), + mockAbortManualExecution: vi.fn(), + mockCancelPausedExecution: vi.fn(), })) vi.mock('@/lib/auth/hybrid', () => ({ @@ -26,19 +31,37 @@ vi.mock('@/lib/execution/manual-cancellation', () => ({ abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args), })) +vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ + PauseResumeManager: { + cancelPausedExecution: (...args: unknown[]) => mockCancelPausedExecution(...args), + }, +})) + vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: (params: unknown) => mockAuthorizeWorkflowByWorkspacePermission(params), })) +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + import { POST } from './route' +const makeRequest = () => + new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', { + method: 'POST', + }) + +const makeParams = () => ({ params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }) }) + describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { beforeEach(() => { vi.clearAllMocks() mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true }) mockAbortManualExecution.mockReturnValue(false) + mockCancelPausedExecution.mockResolvedValue(false) }) it('returns success when cancellation was durably recorded', async () => { @@ -47,14 +70,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { reason: 'recorded', }) - const response = await POST( - new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', { - method: 'POST', - }), - { - params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }), - } - ) + const response = await POST(makeRequest(), makeParams()) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -63,6 +79,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { redisAvailable: true, durablyRecorded: true, locallyAborted: false, + pausedCancelled: false, reason: 'recorded', }) }) @@ -73,14 +90,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { reason: 'redis_unavailable', }) - const response = await POST( - new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', { - method: 'POST', - }), - { - params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }), - } - ) + const response = await POST(makeRequest(), makeParams()) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -89,6 +99,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { redisAvailable: false, durablyRecorded: false, locallyAborted: false, + pausedCancelled: false, reason: 'redis_unavailable', }) }) @@ -99,14 +110,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { reason: 'redis_write_failed', }) - const response = await POST( - new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', { - method: 'POST', - }), - { - params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }), - } - ) + const response = await POST(makeRequest(), makeParams()) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -115,6 +119,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { redisAvailable: true, durablyRecorded: false, locallyAborted: false, + pausedCancelled: false, reason: 'redis_write_failed', }) }) @@ -126,14 +131,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { }) mockAbortManualExecution.mockReturnValue(true) - const response = await POST( - new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', { - method: 'POST', - }), - { - params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }), - } - ) + const response = await POST(makeRequest(), makeParams()) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -142,7 +140,50 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { redisAvailable: false, durablyRecorded: false, locallyAborted: true, + pausedCancelled: false, + reason: 'redis_unavailable', + }) + }) + + it('returns success when a paused HITL execution is cancelled directly in the database', async () => { + mockMarkExecutionCancelled.mockResolvedValue({ + durablyRecorded: false, + reason: 'redis_unavailable', + }) + mockCancelPausedExecution.mockResolvedValue(true) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + executionId: 'ex-1', + redisAvailable: false, + durablyRecorded: false, + locallyAborted: false, + pausedCancelled: true, reason: 'redis_unavailable', }) }) + + it('returns 401 when auth fails', async () => { + mockCheckHybridAuth.mockResolvedValue({ success: false, error: 'Unauthorized' }) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(401) + }) + + it('returns 403 when workflow access is denied', async () => { + mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + message: 'Access denied', + status: 403, + }) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(403) + }) }) From 85d9fd6e7319a9aeff8150eabea793a6ff9bbf00 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 21:13:42 -0700 Subject: [PATCH 13/16] fix(hitl): set endedAt when cancelling paused execution Without endedAt, the logs API running filter (isNull(endedAt)) would keep cancelled paused executions in the running view indefinitely. --- apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 0f9bab9a318..2ba22b4cc7a 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -1287,7 +1287,7 @@ export class PauseResumeManager { await tx .update(workflowExecutionLogs) - .set({ status: 'cancelled' }) + .set({ status: 'cancelled', endedAt: now }) .where(eq(workflowExecutionLogs.executionId, executionId)) return true From 6780e00f984fa961d4d60595988e9c71bc8231be Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 21:28:27 -0700 Subject: [PATCH 14/16] fix(hitl): emit execution:cancelled event to canvas when cancelling paused execution Paused HITL executions have no active SSE stream, so the canvas never received the cancellation event. Now writes execution:cancelled to the event buffer and updates the stream meta so the canvas reconnect path picks it up and shows 'Execution Cancelled'. --- .../[executionId]/cancel/route.test.ts | 17 +++++++++++++++++ .../executions/[executionId]/cancel/route.ts | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index da4401afbbc..73b16fdea1c 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -11,12 +11,18 @@ const { mockMarkExecutionCancelled, mockAbortManualExecution, mockCancelPausedExecution, + mockSetExecutionMeta, + mockWriteEvent, + mockCloseWriter, } = vi.hoisted(() => ({ mockCheckHybridAuth: vi.fn(), mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), mockMarkExecutionCancelled: vi.fn(), mockAbortManualExecution: vi.fn(), mockCancelPausedExecution: vi.fn(), + mockSetExecutionMeta: vi.fn(), + mockWriteEvent: vi.fn(), + mockCloseWriter: vi.fn(), })) vi.mock('@/lib/auth/hybrid', () => ({ @@ -46,6 +52,14 @@ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn(), })) +vi.mock('@/lib/execution/event-buffer', () => ({ + setExecutionMeta: (...args: unknown[]) => mockSetExecutionMeta(...args), + createExecutionEventWriter: () => ({ + write: (...args: unknown[]) => mockWriteEvent(...args), + close: () => mockCloseWriter(), + }), +})) + import { POST } from './route' const makeRequest = () => @@ -62,6 +76,9 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true }) mockAbortManualExecution.mockReturnValue(false) mockCancelPausedExecution.mockResolvedValue(false) + mockSetExecutionMeta.mockResolvedValue(undefined) + mockWriteEvent.mockResolvedValue({ eventId: 1 }) + mockCloseWriter.mockResolvedValue(undefined) }) it('returns success when cancellation was durably recorded', async () => { diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 62203dc5a99..ca6982fd27d 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { markExecutionCancelled } from '@/lib/execution/cancellation' +import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { captureServerEvent } from '@/lib/posthog/server' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' @@ -58,6 +59,17 @@ export async function POST( logger.info('Execution cancelled via local in-process fallback', { executionId }) } else if (pausedCancelled) { logger.info('Paused execution cancelled directly in database', { executionId }) + void setExecutionMeta(executionId, { status: 'cancelled', workflowId }) + const writer = createExecutionEventWriter(executionId) + void writer + .write({ + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { duration: 0 }, + }) + .then(() => writer.close()) } else { logger.warn('Execution cancellation was not durably recorded', { executionId, From 15360c3ecb15a2b7cbe6a3dcb9fe0f41bc76983f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 21:30:46 -0700 Subject: [PATCH 15/16] fix(hitl): isolate cancelPausedExecution failure from successful cancellation Wrap cancelPausedExecution in try/catch so a DB error does not mask a prior successful Redis or in-process cancellation. Also move the resource-collapse side effect in home.tsx to a useEffect to avoid the stale closure on the resources array. --- .../[id]/executions/[executionId]/cancel/route.ts | 7 ++++++- apps/sim/app/workspace/[workspaceId]/home/home.tsx | 12 +++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index ca6982fd27d..889aec910da 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -51,7 +51,12 @@ export async function POST( const cancellation = await markExecutionCancelled(executionId) const locallyAborted = abortManualExecution(executionId) - const pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId) + let pausedCancelled = false + try { + pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId) + } catch (error) { + logger.warn('Failed to cancel paused execution in database', { executionId, error }) + } if (cancellation.durablyRecorded) { logger.info('Execution marked as cancelled in Redis', { executionId }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index c1474fb6fba..1687a1973d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -215,6 +215,12 @@ export function Home({ chatId }: HomeProps = {}) { return () => cancelAnimationFrame(id) }, [resources]) + useEffect(() => { + if (resources.length === 0 && !isResourceCollapsedRef.current) { + collapseResource() + } + }, [resources, collapseResource]) + const handleStopGeneration = useCallback(() => { captureEvent(posthogRef.current, 'task_generation_aborted', { workspace_id: workspaceId, @@ -288,12 +294,8 @@ export function Home({ chatId }: HomeProps = {}) { const resolved = resolveResourceFromContext(context) if (!resolved) return removeResource(resolved.type, resolved.id) - const remaining = resources.filter((r) => !(r.type === resolved.type && r.id === resolved.id)) - if (remaining.length === 0) { - collapseResource() - } }, - [resolveResourceFromContext, removeResource, resources, collapseResource] + [resolveResourceFromContext, removeResource] ) const handleWorkspaceResourceSelect = useCallback( From abf4b5a7ed4030d6f73e1fe3eae72a3600fb2e76 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 13 Apr 2026 21:55:02 -0700 Subject: [PATCH 16/16] fix(hitl): add .catch() to fire-and-forget event buffer calls in cancel route --- .../workflows/[id]/executions/[executionId]/cancel/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 889aec910da..ec65f693501 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -64,7 +64,7 @@ export async function POST( logger.info('Execution cancelled via local in-process fallback', { executionId }) } else if (pausedCancelled) { logger.info('Paused execution cancelled directly in database', { executionId }) - void setExecutionMeta(executionId, { status: 'cancelled', workflowId }) + void setExecutionMeta(executionId, { status: 'cancelled', workflowId }).catch(() => {}) const writer = createExecutionEventWriter(executionId) void writer .write({ @@ -75,6 +75,7 @@ export async function POST( data: { duration: 0 }, }) .then(() => writer.close()) + .catch(() => {}) } else { logger.warn('Execution cancellation was not durably recorded', { executionId,