From 005fa1027b8c772e5c713b41f339ba3a8c7b474f Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 12 Jun 2026 23:05:57 -0700 Subject: [PATCH 01/18] perf(mothership): virtualize chat transcript and isolate input from stream re-renders (#5019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(mothership): virtualize chat transcript and isolate input from stream re-renders Long chats rendered every message into the DOM with no windowing — a custom rAF "progressive list" only smeared the mount cost across frames without capping it. At ~1000 messages this was 52k DOM nodes and a 21s main-thread block on open, and the input toolbar re-rendered on every streamed token. - Virtualize the message list with @tanstack/react-virtual using dynamic measureElement, stable per-row keys, and a tuned size estimate. Only the visible window mounts, so load cost is now flat regardless of transcript length. Remove the now-redundant useProgressiveList hook. - Memoize UserInput and stabilize its callbacks (useCallback in MothershipChat and home) so streaming ticks no longer re-render the entire input toolbar. - Keep the existing useAutoScroll for streaming stick-to-bottom (it reads the virtualizer's real scrollHeight) and add a per-chat scrollToIndex for initial positioning before paint. Measured on a cloned 1032-message chat: time-to-rendered 26.3s -> 1.7s, main-thread blocked 21.4s -> 0.8s, DOM nodes 52k -> 1.4k, typing-while- streaming p-max 104ms -> 26ms. Adds scripts/perf/ harness used to validate. * address review: pending-chat scroll, flash on tail unmount, empty-row gap, per-role estimate - Pending-chat initial scroll (Cursor, high): seed the scrolled-chat guard with a unique sentinel so a not-yet-persisted chat (undefined chatId) with messages still scrolls to bottom instead of being treated as already-scrolled. - Streaming-row flash (review of virtualization): pin the last row in the rendered window via rangeExtractor so scrolling it out of the overscan window and back mid-stream can't unmount/remount it and re-fire the reveal fade. - Empty assistant row gap (Greptile): move the row gap from the virtual-item wrapper into the row content so a null-rendering (finalised, empty) assistant turn collapses to zero height instead of leaving a pb-6 blank slot. - Per-role row-height estimate instead of a single blended constant, so the scrollbar drifts less as off-screen rows resolve. - Drop the scripts/perf harness from the PR. * fix(mothership): preserve user-row top spacing in virtualized layout Restoring pt-3/pt-2 on the user row keeps the exact inter-row rhythm from the old space-y-6 layout: assistant→user gaps stay 24+12px and user→assistant stay 24px, instead of becoming uniform when the gap moved to per-row pb. * fix(mothership): don't re-scroll when a pending chat persists its id The per-chat initial-scroll guard treated undefined→persisted-id as a chat switch and scrolled to the bottom again, yanking the viewport down if the user had scrolled up mid-stream. Treat that transition as the same conversation: adopt the id without re-scrolling. Genuine chat switches (id→different id) still re-scroll. --- .../mothership-chat/mothership-chat.tsx | 233 ++++++++++++------ .../home/components/user-input/user-input.tsx | 10 +- .../app/workspace/[workspaceId]/home/home.tsx | 43 ++-- apps/sim/hooks/use-progressive-list.ts | 133 ---------- 4 files changed, 193 insertions(+), 226 deletions(-) delete mode 100644 apps/sim/hooks/use-progressive-list.ts 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 6bd66067600..e13bab2f1a8 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 @@ -1,6 +1,7 @@ 'use client' import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' import { cn } from '@/lib/core/utils/cn' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' @@ -26,7 +27,6 @@ import type { QueuedMessage, } from '@/app/workspace/[workspaceId]/home/types' import { useAutoScroll } from '@/hooks/use-auto-scroll' -import { useProgressiveList } from '@/hooks/use-progressive-list' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' @@ -61,11 +61,38 @@ interface MothershipChatProps { className?: string } +/** + * Per-role row-height estimates seed the virtualizer before each row is measured. + * They only size the scrollbar for not-yet-rendered rows — every visible row is + * measured precisely via `measureElement` — so approximate values suffice. Split + * by role because user bubbles are short and assistant turns are tall; a single + * blended number would over/under-shoot both and drift the scrollbar more. + */ +const ROW_HEIGHT_ESTIMATE = { + 'mothership-view': { user: 64, assistant: 280 }, + 'copilot-view': { user: 48, assistant: 180 }, +} as const + +/** + * Rows render farther beyond the viewport edges than the default so fast scroll + * and the streaming tail stay painted without a blank flash before measurement. + */ +const OVERSCAN = 6 + +/** + * Initial-scroll sentinel. Distinct from every real `chatId` value — including + * `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires + * even before a chat has an id, instead of treating `undefined` as "already + * scrolled this chat". + */ +const UNSCROLLED = Symbol('unscrolled') + const LAYOUT_STYLES = { 'mothership-view': { scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]', - content: 'mx-auto max-w-[48rem] space-y-6', + sizer: 'relative mx-auto w-full max-w-[48rem]', + rowGap: 'pb-6', userRow: 'flex flex-col items-end gap-[6px] pt-3', attachmentWidth: 'max-w-[70%]', userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2', @@ -75,7 +102,8 @@ const LAYOUT_STYLES = { }, 'copilot-view': { scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4', - content: 'space-y-4', + sizer: 'relative w-full', + rowGap: 'pb-4', userRow: 'flex flex-col items-end gap-[6px] pt-2', attachmentWidth: 'max-w-[85%]', userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2', @@ -201,24 +229,26 @@ export function MothershipChat({ }: MothershipChatProps) { const styles = LAYOUT_STYLES[layout] const isStreamActive = isSending || isReconnecting - const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, { - scrollOnMount: true, - }) + const scrollElementRef = useRef(null) + const { ref: autoScrollRef } = useAutoScroll(isStreamActive) + const setScrollElement = useCallback( + (el: HTMLDivElement | null) => { + scrollElementRef.current = el + autoScrollRef(el) + }, + [autoScrollRef] + ) + const hasMessages = messages.length > 0 - const stagingKey = chatId ?? 'pending-chat' - const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey) - const stagedMessageCount = stagedMessages.length - const stagedOffset = messages.length - stagedMessages.length - const precedingUserContentByIndex = useMemo(() => { - const out: Array = [] - let lastUserContent: string | undefined - for (const [index, message] of messages.entries()) { - out[index] = lastUserContent - if (message.role === 'user') lastUserContent = message.content - } - return out - }, [messages]) - const assistantTurnKeyByIndex = useMemo(() => { + + /** + * Stable per-row identity for virtualizer measurement caching and React + * reconciliation. User rows key on their message id; assistant rows key on + * their turn position (`assistant::`) so a streaming + * placeholder keeps the same element — and its smooth-text state — when the + * persisted message arrives with a new id. + */ + const rowKeyByIndex = useMemo(() => { const out: string[] = [] let lastUserId: string | undefined let ordinal = 0 @@ -226,14 +256,61 @@ export function MothershipChat({ if (message.role === 'user') { lastUserId = message.id ordinal = 0 + out[index] = message.id } else { out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id } } return out }, [messages]) - const initialScrollDoneRef = useRef(false) + + const precedingUserContentByIndex = useMemo(() => { + const out: Array = [] + let lastUserContent: string | undefined + for (const [index, message] of messages.entries()) { + out[index] = lastUserContent + if (message.role === 'user') lastUserContent = message.content + } + return out + }, [messages]) + + /** + * Always keep the last row in the rendered window. It is the live/streaming + * row; unmounting it (by scrolling far enough up that it leaves the overscan + * window) and remounting it mid-stream would reset its smooth-text reveal + * state and re-fire the fade-in animation — a visible flash. Pinning it costs + * one extra always-mounted row. + */ + const lastIndex = messages.length - 1 + const rangeExtractor = useCallback( + (range: Range) => { + const indexes = defaultRangeExtractor(range) + if (lastIndex >= 0 && !indexes.includes(lastIndex)) { + indexes.push(lastIndex) + } + return indexes + }, + [lastIndex] + ) + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: (index) => { + const estimate = ROW_HEIGHT_ESTIMATE[layout] + return messages[index]?.role === 'user' ? estimate.user : estimate.assistant + }, + overscan: OVERSCAN, + getItemKey: (index) => rowKeyByIndex[index] ?? index, + rangeExtractor, + }) + + const scrolledChatRef = useRef(UNSCROLLED) const userInputRef = useRef(null) + const messageQueueRef = useRef(messageQueue) + useEffect(() => { + messageQueueRef.current = messageQueue + }, [messageQueue]) const onSubmitRef = useRef(onSubmit) useEffect(() => { @@ -243,37 +320,48 @@ export function MothershipChat({ onSubmitRef.current(id) }, []) - function handleSendQueuedHead() { - const topMessage = messageQueue[0] + const handleSendQueuedHead = useCallback(() => { + const topMessage = messageQueueRef.current[0] if (!topMessage) return void onSendQueuedMessage(topMessage.id) - } + }, [onSendQueuedMessage]) - function handleEditQueued(id: string) { - const msg = onEditQueuedMessage(id) - if (msg) userInputRef.current?.loadQueuedMessage(msg) - } + const handleEditQueued = useCallback( + (id: string) => { + const msg = onEditQueuedMessage(id) + if (msg) userInputRef.current?.loadQueuedMessage(msg) + }, + [onEditQueuedMessage] + ) - function handleEditQueuedTail() { - const tail = messageQueue[messageQueue.length - 1] + const handleEditQueuedTail = useCallback(() => { + const tail = messageQueueRef.current[messageQueueRef.current.length - 1] if (!tail) return handleEditQueued(tail.id) - } + }, [handleEditQueued]) + /** + * Land at the most recent message once per chat — on open and when switching + * chats. The ref tracks which `chatId` we last scrolled for (seeded with + * {@link UNSCROLLED} so a pending, id-less chat still scrolls on first mount), + * so it re-fires on a genuine chat switch, including between chats of equal + * length. A pending chat persisting its id (`undefined` → string) is the SAME + * conversation, so adopt the id without re-scrolling — otherwise the viewport + * would snap back to the bottom after the user scrolled up mid-stream. Runs + * before paint so a long transcript never flashes at the top. Subsequent + * growth within the same chat is handled by {@link useAutoScroll}'s streaming + * sticky-scroll, not here. + */ useLayoutEffect(() => { - if (!hasMessages) { - initialScrollDoneRef.current = false - return - } - if (initialScrollDoneRef.current || initialScrollBlocked) return - initialScrollDoneRef.current = true - scrollToBottom() - }, [hasMessages, initialScrollBlocked, scrollToBottom]) + const scrolledFor = scrolledChatRef.current + if (!hasMessages || initialScrollBlocked || scrolledFor === chatId) return + const isPendingPersist = scrolledFor === undefined && chatId !== undefined + scrolledChatRef.current = chatId + if (isPendingPersist) return + virtualizer.scrollToIndex(lastIndex, { align: 'end' }) + }, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer]) - useLayoutEffect(() => { - if (!isStaging || initialScrollBlocked || !initialScrollDoneRef.current) return - scrollToBottom() - }, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom]) + const virtualItems = virtualizer.getVirtualItems() return (
-
+
{isLoading && !hasMessages ? ( ) : ( -
- {stagedMessages.map((msg, localIndex) => { - const index = stagedOffset + localIndex - if (msg.role === 'user') { - return ( - - ) - } - - const isLast = index === messages.length - 1 +
+ {virtualItems.map((virtualItem) => { + const index = virtualItem.index + const msg = messages[index] + const isLast = index === lastIndex return ( - +
+ {msg.role === 'user' ? ( + + ) : ( + + )} +
) })}
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 aaaa2a0659d..b946d4f5fb2 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 @@ -3,6 +3,7 @@ import type React from 'react' import { forwardRef, + memo, useCallback, useEffect, useImperativeHandle, @@ -145,7 +146,7 @@ export interface UserInputHandle { populatePrompt: (text: string) => void } -export const UserInput = forwardRef(function UserInput( +const UserInputImpl = forwardRef(function UserInput( { defaultValue = '', draftScopeKey, @@ -1445,3 +1446,10 @@ export const UserInput = forwardRef(function Us
) }) + +/** + * Memoized so streaming ticks in the parent transcript — which re-render + * {@link MothershipChat} on every chunk — do not re-render the entire input + * toolbar. Relies on callers passing stable callbacks (see `MothershipChat`). + */ +export const UserInput = memo(UserInputImpl) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 11bf7dec41b..b22b783d7e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -233,36 +233,35 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom } }, [resources, collapseResource]) - function handleStopGeneration() { + const handleStopGeneration = useCallback(() => { captureEvent(posthogRef.current, 'task_generation_aborted', { workspace_id: workspaceId, view: 'mothership', request_id: getCurrentRequestId(), }) void stopGeneration().catch(() => {}) - } - - function handleSubmit( - text: string, - fileAttachments?: FileAttachmentForApi[], - contexts?: ChatContext[] - ) { - const trimmed = text.trim() - if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return - - captureEvent(posthogRef.current, 'task_message_sent', { - workspace_id: workspaceId, - has_attachments: !!(fileAttachments && fileAttachments.length > 0), - has_contexts: !!(contexts && contexts.length > 0), - is_new_task: !chatId, - }) + }, [workspaceId, getCurrentRequestId, stopGeneration]) + + const handleSubmit = useCallback( + (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { + const trimmed = text.trim() + if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return + + captureEvent(posthogRef.current, 'task_message_sent', { + workspace_id: workspaceId, + has_attachments: !!(fileAttachments && fileAttachments.length > 0), + has_contexts: !!(contexts && contexts.length > 0), + is_new_task: !chatId, + }) - if (initialViewInputRef.current) { - setIsInputEntering(true) - } + if (initialViewInputRef.current) { + setIsInputEntering(true) + } - sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts) - } + sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts) + }, + [workspaceId, chatId, sendMessage] + ) useEffect(() => { const handler = (e: Event) => { diff --git a/apps/sim/hooks/use-progressive-list.ts b/apps/sim/hooks/use-progressive-list.ts deleted file mode 100644 index bf60f4d67b0..00000000000 --- a/apps/sim/hooks/use-progressive-list.ts +++ /dev/null @@ -1,133 +0,0 @@ -'use client' - -import { useEffect, useLayoutEffect, useRef, useState } from 'react' - -interface ProgressiveListOptions { - /** Number of items to render in the initial batch (most recent items) */ - initialBatch?: number - /** Number of items to add per animation frame */ - batchSize?: number -} - -const DEFAULTS = { - initialBatch: 10, - batchSize: 5, -} satisfies Required - -interface ProgressiveListState { - key: string - count: number - caughtUp: boolean -} - -function createInitialState( - key: string, - itemCount: number, - initialBatch: number -): ProgressiveListState { - const count = Math.min(itemCount, initialBatch) - return { - key, - count, - caughtUp: itemCount > 0 && count >= itemCount, - } -} - -/** - * Progressively renders a list of items so that first paint is fast. - * - * On mount (or when `key` changes), only the most recent `initialBatch` - * items are rendered. The rest are added in `batchSize` increments via - * `requestAnimationFrame`. - * - * @param items Full list of items to render. - * @param key A session/conversation identifier. When it changes, - * staging restarts for the new list. - * @param options Tuning knobs for batch sizes. - * @returns The currently staged (visible) subset of items. - */ -export function useProgressiveList( - items: T[], - key: string, - options?: ProgressiveListOptions -): { staged: T[]; isStaging: boolean } { - const initialBatch = Math.max(0, options?.initialBatch ?? DEFAULTS.initialBatch) - const batchSize = Math.max(1, options?.batchSize ?? DEFAULTS.batchSize) - const [state, setState] = useState(() => createInitialState(key, items.length, initialBatch)) - const latestItemCountRef = useRef(items.length) - - useLayoutEffect(() => { - latestItemCountRef.current = items.length - }, [items.length]) - - const renderState = - state.key === key && (state.count > 0 || items.length === 0 || state.caughtUp) - ? state - : createInitialState(key, items.length, initialBatch) - - useEffect(() => { - setState((prev) => { - if (prev.key !== key) { - return createInitialState(key, items.length, initialBatch) - } - - if (items.length === 0) { - if (prev.count === 0 && !prev.caughtUp) { - return prev - } - return { key, count: 0, caughtUp: false } - } - - if (prev.caughtUp) { - if (prev.count === items.length) { - return prev - } - return { key, count: items.length, caughtUp: true } - } - - const minimumCount = Math.min(items.length, initialBatch) - if (prev.count >= minimumCount && prev.count <= items.length) { - return prev - } - - const count = Math.min(items.length, Math.max(prev.count, minimumCount)) - return { - key, - count, - caughtUp: count >= items.length, - } - }) - }, [key, items.length, initialBatch]) - - useEffect(() => { - if (state.key !== key || state.caughtUp || state.count >= items.length) { - return - } - - const frame = requestAnimationFrame(() => { - setState((prev) => { - if (prev.key !== key || prev.caughtUp) { - return prev - } - - const itemCount = latestItemCountRef.current - const count = Math.min(itemCount, prev.count + batchSize) - return { - key, - count, - caughtUp: count >= itemCount, - } - }) - }) - - return () => cancelAnimationFrame(frame) - }, [state.key, state.count, state.caughtUp, key, items.length, batchSize]) - - const effectiveCount = renderState.caughtUp - ? items.length - : Math.min(renderState.count, items.length) - const staged = items.slice(Math.max(0, items.length - effectiveCount)) - const isStaging = effectiveCount < items.length - - return { staged, isStaging } -} From 9a4c9d2af158979d1d1a8dae0f378ca6cdac9a6b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 12 Jun 2026 23:58:03 -0700 Subject: [PATCH 02/18] fix(db-part-3): bound cross-request shared promises against pool wedge (#5021) * fix(db-part-3): bound cross-request shared promises against pool wedge * address comments --- apps/sim/app/api/auth/oauth/utils.ts | 12 ++- apps/sim/app/api/function/execute/route.ts | 11 +- .../__tests__/singleflight.test.ts | 100 +++++++++++++++++- apps/sim/lib/concurrency/singleflight.ts | 70 ++++++++++-- apps/sim/lib/mcp/oauth/storage.test.ts | 36 +++++++ apps/sim/lib/mcp/oauth/storage.ts | 50 ++++++++- apps/sim/lib/oauth/oauth.ts | 10 ++ apps/sim/lib/webhooks/deploy.ts | 14 +++ apps/sim/tools/vanta/utils.ts | 8 ++ 9 files changed, 292 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index bbfdb0135be..732f157d581 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -358,7 +358,7 @@ async function performCoalescedRefresh({ const lockKey = `oauth:refresh:${accountId}` - return coalesceLocally(lockKey, () => + const refreshPromise = coalesceLocally(lockKey, () => withLeaderLock({ key: lockKey, onLeader: async () => { @@ -429,6 +429,16 @@ async function performCoalescedRefresh({ }, }) ) + + try { + return await refreshPromise + } catch (error) { + logger.error('Coalesced refresh did not settle', { + ...logContext, + error: toError(error).message, + }) + return null + } } export async function getOAuthToken(userId: string, providerId: string): Promise { diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index a3035607e52..9ac6ab2da77 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -115,10 +115,13 @@ let typescriptModulePromise: Promise | null = null async function loadTypeScriptModule(): Promise { if (!typescriptModulePromise) { - typescriptModulePromise = import('typescript').then((mod) => { - const tsModule = (mod?.default ?? mod) as TypeScriptModule - return tsModule - }) + typescriptModulePromise = import('typescript').then( + (mod) => (mod?.default ?? mod) as TypeScriptModule, + (error) => { + typescriptModulePromise = null + throw error + } + ) } return typescriptModulePromise diff --git a/apps/sim/lib/concurrency/__tests__/singleflight.test.ts b/apps/sim/lib/concurrency/__tests__/singleflight.test.ts index f911266be7b..0599127bb5f 100644 --- a/apps/sim/lib/concurrency/__tests__/singleflight.test.ts +++ b/apps/sim/lib/concurrency/__tests__/singleflight.test.ts @@ -3,7 +3,11 @@ */ import { sleep } from '@sim/utils/helpers' import { afterEach, describe, expect, it, vi } from 'vitest' -import { __resetCoalesceLocallyForTests, coalesceLocally } from '@/lib/concurrency/singleflight' +import { + __resetCoalesceLocallyForTests, + CoalesceSettleTimeoutError, + coalesceLocally, +} from '@/lib/concurrency/singleflight' afterEach(() => { __resetCoalesceLocallyForTests() @@ -57,9 +61,103 @@ describe('coalesceLocally', () => { await expect(coalesceLocally('rejection', fn)).rejects.toThrow('fail 2') }) + it('surfaces a synchronously-thrown fn error and evicts the entry', async () => { + const fn = vi.fn((): Promise => { + throw new Error('sync boom') + }) + + // The real error must surface (not a TDZ ReferenceError from the evict + // closure) and the entry must be evicted so the next call retries. + await expect(coalesceLocally('sync-throw', fn)).rejects.toThrow('sync boom') + await expect(coalesceLocally('sync-throw', fn)).rejects.toThrow('sync boom') + expect(fn).toHaveBeenCalledTimes(2) + }) + it('does not coalesce across distinct keys', async () => { const fn = vi.fn(async () => 'value') await Promise.all([coalesceLocally('a', fn), coalesceLocally('b', fn)]) expect(fn).toHaveBeenCalledTimes(2) }) + + it('rejects all awaiters and evicts the entry when the producer misses the settle deadline', async () => { + vi.useFakeTimers() + try { + let resolveHung: (value: string) => void + const hung = vi.fn( + () => + new Promise((resolve) => { + resolveHung = resolve + }) + ) + + const a = coalesceLocally('wedged', hung) + const b = coalesceLocally('wedged', hung) + const aAssertion = expect(a).rejects.toBeInstanceOf(CoalesceSettleTimeoutError) + const bAssertion = expect(b).rejects.toBeInstanceOf(CoalesceSettleTimeoutError) + + await vi.advanceTimersByTimeAsync(30_000) + await aAssertion + await bAssertion + expect(hung).toHaveBeenCalledTimes(1) + + const fresh = vi.fn(async () => 'recovered') + await expect(coalesceLocally('wedged', fresh)).resolves.toBe('recovered') + expect(fresh).toHaveBeenCalledTimes(1) + + resolveHung!('late') + } finally { + vi.useRealTimers() + } + }) + + it('a timed-out producer settling late does not evict its successor', async () => { + vi.useFakeTimers() + try { + let resolveOld: (value: string) => void + const old = coalesceLocally( + 'late-settle', + () => + new Promise((resolve) => { + resolveOld = resolve + }), + 1_000 + ) + const oldAssertion = expect(old).rejects.toBeInstanceOf(CoalesceSettleTimeoutError) + await vi.advanceTimersByTimeAsync(1_000) + await oldAssertion + + let resolveNew: (value: string) => void + const successor = coalesceLocally( + 'late-settle', + () => + new Promise((resolve) => { + resolveNew = resolve + }) + ) + + resolveOld!('late') + await vi.advanceTimersByTimeAsync(0) + + const joined = coalesceLocally('late-settle', async () => 'should-not-run') + expect(joined).toBe(successor) + + resolveNew!('new-value') + await expect(successor).resolves.toBe('new-value') + } finally { + vi.useRealTimers() + } + }) + + it('does not fire the deadline for producers that settle in time', async () => { + vi.useFakeTimers() + try { + const value = await coalesceLocally('prompt', async () => 'ok', 1_000) + expect(value).toBe('ok') + + await vi.advanceTimersByTimeAsync(2_000) + await expect(coalesceLocally('prompt', async () => 'again', 1_000)).resolves.toBe('again') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/apps/sim/lib/concurrency/singleflight.ts b/apps/sim/lib/concurrency/singleflight.ts index f15ae06f9da..a41fbeda455 100644 --- a/apps/sim/lib/concurrency/singleflight.ts +++ b/apps/sim/lib/concurrency/singleflight.ts @@ -1,19 +1,69 @@ const inflight = new Map>() -export function coalesceLocally(key: string, fn: () => Promise): Promise { +/** + * Default deadline for a coalesced producer to settle. Joiners share the + * producer's promise, so without a deadline a single hung producer wedges + * every future caller for that key until process restart. + */ +const DEFAULT_SETTLE_TIMEOUT_MS = 30_000 + +/** + * Thrown to all awaiters when a coalesced producer fails to settle within + * its deadline. The entry is evicted first, so the next caller mints a + * fresh producer instead of joining the wedged one. + */ +export class CoalesceSettleTimeoutError extends Error { + constructor(key: string, timeoutMs: number) { + super(`Coalesced producer for "${key}" did not settle within ${timeoutMs}ms`) + this.name = 'CoalesceSettleTimeoutError' + } +} + +/** + * Deduplicates concurrent async work by key within this process: the first + * caller runs `fn`, every concurrent caller for the same key shares its + * promise. The entry is evicted when the producer settles (either way) or + * when the settle deadline fires, whichever comes first. The underlying + * `fn` is not cancelled on timeout — it keeps running detached, but no new + * caller will join it. + */ +export function coalesceLocally( + key: string, + fn: () => Promise, + settleTimeoutMs: number = DEFAULT_SETTLE_TIMEOUT_MS +): Promise { const existing = inflight.get(key) as Promise | undefined if (existing) return existing - const promise = (async () => { - try { - return await fn() - } finally { - inflight.delete(key) - } - })() + let timer: ReturnType | undefined + const evict = () => { + if (inflight.get(key) === guarded) inflight.delete(key) + } + + const guarded: Promise = Promise.race([ + (async () => { + try { + // Defer fn() to a microtask so a synchronous throw surfaces as a + // rejection after `guarded` and the timer are initialized. Calling it + // inline would run the finally below during construction, touching + // `guarded` in its temporal dead zone and masking fn's real error. + return await Promise.resolve().then(fn) + } finally { + clearTimeout(timer) + evict() + } + })(), + new Promise((_, reject) => { + timer = setTimeout(() => { + evict() + reject(new CoalesceSettleTimeoutError(key, settleTimeoutMs)) + }, settleTimeoutMs) + timer.unref?.() + }), + ]) - inflight.set(key, promise) - return promise + inflight.set(key, guarded) + return guarded } export function __resetCoalesceLocallyForTests(): void { diff --git a/apps/sim/lib/mcp/oauth/storage.test.ts b/apps/sim/lib/mcp/oauth/storage.test.ts index 61455b36135..b9d07e779f7 100644 --- a/apps/sim/lib/mcp/oauth/storage.test.ts +++ b/apps/sim/lib/mcp/oauth/storage.test.ts @@ -221,6 +221,42 @@ describe('withMcpOauthRefreshLock', () => { } }) + it('bounds the queue wait: callers stalled behind a wedged link reject without running fn', async () => { + vi.useFakeTimers() + try { + mockAcquireLock.mockResolvedValue(true) + let resolveFirst: (value: string) => void + const hungFn = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + const queuedFn = vi.fn(async () => 'second') + + const first = withMcpOauthRefreshLock('row-stall', hungFn) + const second = withMcpOauthRefreshLock('row-stall', queuedFn) + const secondAssertion = expect(second).rejects.toThrow(/stalled for/) + + await vi.advanceTimersByTimeAsync(90_000) + await secondAssertion + expect(queuedFn).not.toHaveBeenCalled() + + // The wedged link is untouched by the queue deadline (its own fn keeps + // the lock, protecting a possibly mid-rotation refresh) and the row + // heals once it settles — including skipping the abandoned link's fn. + resolveFirst!('first') + await expect(first).resolves.toBe('first') + + await expect(withMcpOauthRefreshLock('row-stall', async () => 'healed')).resolves.toBe( + 'healed' + ) + expect(queuedFn).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('extends the lock TTL while fn() is running so long refreshes do not lose the lock', async () => { vi.useFakeTimers() try { diff --git a/apps/sim/lib/mcp/oauth/storage.ts b/apps/sim/lib/mcp/oauth/storage.ts index aca0fbf5ec6..b2e0a7b13b5 100644 --- a/apps/sim/lib/mcp/oauth/storage.ts +++ b/apps/sim/lib/mcp/oauth/storage.ts @@ -237,7 +237,10 @@ export async function clearState(rowId: string): Promise { * Two-tier serialization (each caller runs its OWN `fn()` — callers consume * `McpClient` instances that can't be shared, unlike a scalar access token): * 1) In-process: per-row Promise chain. Concurrent callers queue; each - * runs `fn()` after the previous settles. + * runs `fn()` after the previous settles. The queue wait is bounded — + * a caller whose turn does not arrive within + * {@link REFRESH_QUEUE_WAIT_TIMEOUT_MS} rejects without ever running + * its `fn()`, so a wedged link cannot accumulate callers indefinitely. * 2) Cross-process: Redis mutex (`acquireLock` / `releaseLock`) with a TTL * watchdog that periodically extends the lock while `fn()` runs, so * long-running refreshes don't drop the lock and let another process @@ -251,18 +254,59 @@ const REFRESH_LOCK_EXTEND_INTERVAL_MS = 5_000 const REFRESH_POLL_INTERVAL_MS = 100 const REFRESH_MAX_WAIT_MS = 30_000 +/** + * Deadline on the in-process QUEUE WAIT only — the time a caller spends + * waiting for its turn behind queued predecessors. Without it, one hung + * link wedges every subsequent caller for that row until process restart. + * Sized to survive one legitimately slow predecessor: up to + * REFRESH_MAX_WAIT_MS of cross-process lock contention plus the MCP SDK's + * 60s initialize timeout. Deliberately NOT applied to the caller's own + * `fn()` run — aborting a running `fn()` would orphan a connected + * `McpClient` and abandon a possibly mid-rotation refresh; `fn()` is + * bounded by its own SDK/HTTP/Redis timeouts instead. + */ +const REFRESH_QUEUE_WAIT_TIMEOUT_MS = 90_000 + const inflightChains = new Map>() export async function withMcpOauthRefreshLock(rowId: string, fn: () => Promise): Promise { const lockKey = `mcp:oauth:refresh:${rowId}` const prev = inflightChains.get(lockKey) ?? Promise.resolve() - const next = prev.catch(() => undefined).then(() => runWithRedisMutex(lockKey, rowId, fn)) + const prevSettled = prev.catch(() => undefined) + + let queueTimedOut = false + const next = prevSettled.then(() => { + if (queueTimedOut) { + throw new Error(`MCP OAuth refresh queue for ${rowId} abandoned after timeout`) + } + return runWithRedisMutex(lockKey, rowId, fn) + }) inflightChains.set(lockKey, next) const cleanup = () => { if (inflightChains.get(lockKey) === next) inflightChains.delete(lockKey) } next.then(cleanup, cleanup) - return next as Promise + + let queueTimer: ReturnType | undefined + const queueDeadline = new Promise((_, reject) => { + queueTimer = setTimeout(() => { + queueTimedOut = true + reject( + new Error( + `MCP OAuth refresh queue for ${rowId} stalled for ${REFRESH_QUEUE_WAIT_TIMEOUT_MS}ms` + ) + ) + }, REFRESH_QUEUE_WAIT_TIMEOUT_MS) + queueTimer.unref?.() + }) + + try { + await Promise.race([prevSettled, queueDeadline]) + } finally { + clearTimeout(queueTimer) + } + + return next } async function runWithRedisMutex( diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 00dc93cb243..28c30e0c238 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1496,6 +1496,15 @@ function extractErrorCode(value: unknown): string | undefined { return undefined } +/** + * Hard deadline on the token-endpoint exchange. This function does not coalesce + * on its own; its sole production caller (`performCoalescedRefresh` in the OAuth + * utils) shares one in-flight refresh across concurrent callers for a credential. + * Without this bound a hung endpoint would wedge every joiner on that key until + * the undici socket defaults (~5 min) gave up. + */ +const TOKEN_REFRESH_TIMEOUT_MS = 15_000 + export async function refreshOAuthToken( providerId: string, refreshToken: string @@ -1511,6 +1520,7 @@ export async function refreshOAuthToken( method: 'POST', headers, body: useJsonBody ? JSON.stringify(bodyParams) : new URLSearchParams(bodyParams).toString(), + signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), }) if (!response.ok) { diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 8b204288b2a..686c974df5a 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -932,6 +932,10 @@ async function persistCreatedWebhookRecordAfterCleanupFailure({ * Removes external subscriptions and deletes webhook records from the database. * * @param skipExternalCleanup - If true, skip external subscription cleanup (already done elsewhere) + * @param shouldDeleteWebhook - Best-effort early-exit probe. Its implementations + * query the global pool, so it MUST only be awaited while no transaction is open. + * See {@link deleteWebhookRecordAfterCleanup} for the in-transaction recheck that + * makes this probe non-authoritative. */ export async function cleanupWebhooksForWorkflow( workflowId: string, @@ -1036,6 +1040,16 @@ export async function cleanupWebhooksForWorkflow( ) } +/** + * Deletes a webhook record unless the deployment became active again. + * + * `shouldDeleteWebhook` is awaited BEFORE the transaction opens — its + * implementations query the global pool, so running it inside the + * transaction would nest a second pooled checkout under the held + * connection. The transaction does not need it: the `FOR UPDATE` select + * on the deployment version row is the authoritative recheck, and it + * aborts the delete if the version was reactivated. + */ async function deleteWebhookRecordAfterCleanup(params: { workflowId: string deploymentVersionId?: string | null diff --git a/apps/sim/tools/vanta/utils.ts b/apps/sim/tools/vanta/utils.ts index 356ff7b32dc..4a9c3a00ccc 100644 --- a/apps/sim/tools/vanta/utils.ts +++ b/apps/sim/tools/vanta/utils.ts @@ -153,6 +153,13 @@ const vantaTokenExchanges = new Map>() /** Evict cached tokens well before their one-hour expiry. */ const VANTA_TOKEN_EXPIRY_BUFFER_MS = 10 * 60 * 1000 +/** + * Hard deadline on the token-endpoint exchange. The exchange promise is + * shared across concurrent callers, so a hung endpoint without this bound + * would wedge every joiner until the undici socket defaults (~5 min) gave up. + */ +const VANTA_TOKEN_EXCHANGE_TIMEOUT_MS = 15_000 + /** * Derives the cache key for a credential set. The client secret is included * only as a SHA-256 digest so plaintext secrets never persist in the @@ -183,6 +190,7 @@ async function exchangeVantaToken(params: VantaTokenParams, cacheKey: string): P grant_type: 'client_credentials', }), cache: 'no-store', + signal: AbortSignal.timeout(VANTA_TOKEN_EXCHANGE_TIMEOUT_MS), }) const data: unknown = await response.json().catch(() => null) From 51733b8d858bb7a2636e954e1ac415f6fc2623bd Mon Sep 17 00:00:00 2001 From: Salar <126340046+salarkhannn@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:18:51 +0500 Subject: [PATCH 03/18] fix(db): correct misleading error message when DATABASE_REPLICA_URL is malformed (#5023) * v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * fix(db): correct misleading error message when DATABASE_REPLICA_URL is malformed The error message said reads fall back to the primary when unset, but the code throws a fatal error instead. The misleading parenthetical contradicted actual behavior and could waste time during incident response when an operator sees this message and expects graceful degradation. --------- Co-authored-by: Waleed Co-authored-by: Theodore Li Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti Co-authored-by: Theodore Li --- packages/db/db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/db.ts b/packages/db/db.ts index dae0815ed15..28edc09818c 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -31,7 +31,7 @@ export const db = drizzle(postgresClient, { schema }) const replicaUrl = process.env.DATABASE_REPLICA_URL if (replicaUrl && !/^postgres(ql)?:\/\//.test(replicaUrl)) { throw new Error( - 'DATABASE_REPLICA_URL is set but is not a postgres:// DSN — fix or unset it (reads fall back to the primary when unset)' + 'DATABASE_REPLICA_URL is set but is not a postgres:// DSN — fix the URL or unset the variable' ) } From eb1009da1c76f75fe37b1dc583f61dcc2baf421e Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 13 Jun 2026 10:14:07 -0700 Subject: [PATCH 04/18] =?UTF-8?q?improvement(react-query):=20codebase-wide?= =?UTF-8?q?=20audit=20=E2=80=94=20server-state=20hooks,=20webhook=20cohere?= =?UTF-8?q?nce,=20resume=20migration=20(#5024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(react-query): codebase-wide audit — server-state hooks, webhook coherence, resume migration * chore(react-query): add static pattern linter + address review feedback - add scripts/check-react-query-patterns.ts (staleTime/signal/key-factory/inline-key enforcement) wired into CI as check:react-query; strict zone hooks/queries/**, ratchet elsewhere - fix(resume): use same-origin relative path for resume POST (getBaseUrl could cross origin on whitelabel/preview hosts and drop session cookies) — Cursor Bugbot - remove explanatory inline comments in favor of TSDoc per repo convention --- .claude/rules/sim-queries.md | 4 + .github/workflows/test-build.yml | 3 + .../demo-request/demo-request-modal.tsx | 5 +- .../[executionId]/[contextId]/route.ts | 7 +- .../[executionId]/resume-page-client.tsx | 538 ++++++------------ .../workflow-sidebar/workflow-sidebar.tsx | 6 +- .../workflow-block/hooks/use-webhook-info.ts | 65 +-- apps/sim/ee/data-drains/hooks/data-drains.ts | 25 +- apps/sim/ee/sso/hooks/sso.ts | 4 +- apps/sim/ee/whitelabeling/hooks/whitelabel.ts | 3 +- apps/sim/hooks/queries/a2a/agents.ts | 8 +- apps/sim/hooks/queries/academy.ts | 5 +- apps/sim/hooks/queries/admin-users.ts | 41 +- apps/sim/hooks/queries/api-keys.ts | 12 +- apps/sim/hooks/queries/byok-keys.ts | 19 +- apps/sim/hooks/queries/copilot-keys.ts | 8 +- apps/sim/hooks/queries/credential-sets.ts | 52 +- apps/sim/hooks/queries/custom-tools.ts | 3 +- apps/sim/hooks/queries/deployments.ts | 4 +- apps/sim/hooks/queries/environment.ts | 18 +- apps/sim/hooks/queries/general-settings.ts | 2 +- apps/sim/hooks/queries/inbox.ts | 12 +- apps/sim/hooks/queries/invitations.ts | 5 +- apps/sim/hooks/queries/kb/connectors.ts | 15 +- apps/sim/hooks/queries/kb/knowledge.ts | 11 +- apps/sim/hooks/queries/mothership-admin.ts | 4 +- .../hooks/queries/oauth/oauth-connections.ts | 11 +- .../hooks/queries/oauth/oauth-credentials.ts | 7 +- apps/sim/hooks/queries/providers.ts | 7 +- apps/sim/hooks/queries/resume-execution.ts | 193 +++++++ apps/sim/hooks/queries/schedules.ts | 75 +-- apps/sim/hooks/queries/skills.ts | 2 + apps/sim/hooks/queries/subscription.ts | 56 +- apps/sim/hooks/queries/tables.ts | 3 +- apps/sim/hooks/queries/user-profile.ts | 2 +- .../hooks/queries/utils/custom-tool-keys.ts | 3 +- .../queries/utils/optimistic-mutation.ts | 2 +- apps/sim/hooks/queries/utils/workflow-keys.ts | 4 +- apps/sim/hooks/queries/webhooks.ts | 35 +- .../sim/hooks/queries/workflow-mcp-servers.ts | 72 +-- apps/sim/hooks/queries/workflows.ts | 26 +- apps/sim/hooks/use-permission-config.ts | 7 +- apps/sim/lib/api/contracts/workflows.ts | 25 + package.json | 1 + .../check-react-query-patterns.baseline.json | 4 + scripts/check-react-query-patterns.ts | 276 +++++++++ 46 files changed, 1048 insertions(+), 642 deletions(-) create mode 100644 apps/sim/hooks/queries/resume-execution.ts create mode 100644 scripts/check-react-query-patterns.baseline.json create mode 100644 scripts/check-react-query-patterns.ts diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index 7df7244849e..f1d7270f0ca 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -137,3 +137,7 @@ const handler = useCallback(() => { - **Query hooks**: `useEntity`, `useEntityList` - **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`, `useDeleteEntity` - **Fetch functions**: `fetchEntity`, `fetchEntities` (private) + +## Enforcement + +`scripts/check-react-query-patterns.ts` (`bun run check:react-query`, run in CI) statically enforces these conventions: every `useQuery`/`useInfiniteQuery`/`useSuspenseQuery` declares an explicit `staleTime`, inline `queryFn`s destructure `signal`, `queryKey`s reference a colocated factory rather than an inline literal, and every `*Keys` factory in `hooks/queries/**` exposes an `all` root key. `hooks/queries/**` is a zero-tolerance zone; the rest of `apps/sim/**` is ratcheted against `scripts/check-react-query-patterns.baseline.json`. For a genuine exception, put `// rq-lint-allow: ` on the line directly above the flagged construct. diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e59102ebd58..2f1df75a380 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -112,6 +112,9 @@ jobs: - name: Zustand v5 selector audit run: bun run check:zustand-v5 + - name: React Query pattern audit + run: bun run check:react-query + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx index 3c254742348..f274e39c614 100644 --- a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx +++ b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx @@ -1,6 +1,7 @@ 'use client' import { useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' import { useMutation } from '@tanstack/react-query' import { ChipCombobox, @@ -128,9 +129,7 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP } const submitError = demoMutation.isError - ? demoMutation.error instanceof Error - ? demoMutation.error.message - : 'Failed to submit demo request. Please try again.' + ? getErrorMessage(demoMutation.error, 'Failed to submit demo request. Please try again.') : null return ( diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 47f2f381168..cfc05b5a1e9 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -2,7 +2,10 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { resumeWorkflowExecutionContextContract } from '@/lib/api/contracts/workflows' +import { + getPauseContextDetailContract, + resumeWorkflowExecutionContextContract, +} from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { AuthType } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' @@ -310,7 +313,7 @@ export const GET = withRouteHandler( params: Promise<{ workflowId: string; executionId: string; contextId: string }> } ) => { - const parsed = await parseRequest(resumeWorkflowExecutionContextContract, request, context) + const parsed = await parseRequest(getPauseContextDetailContract, request, context) if (!parsed.success) return parsed.response const { workflowId, executionId, contextId } = parsed.data.params diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx index 4f962ccce10..a802d6ebe3b 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1,12 +1,9 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { RefreshCw } from 'lucide-react' import { useRouter } from 'next/navigation' - -const logger = createLogger('ResumePage') - import { Badge, Button, @@ -29,19 +26,17 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { requestJson } from '@/lib/api/client/request' -import { resumeWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import Navbar from '@/app/(landing)/components/navbar/navbar' import { useBrandConfig } from '@/ee/whitelabeling' -import type { ResumeStatus } from '@/executor/types' - -interface ResumeLinks { - apiUrl: string - uiUrl: string - contextId: string - executionId: string - workflowId: string -} +import { + type PauseContextDetail, + type PausedExecutionDetail, + type PausePointWithQueue, + resumeKeys, + usePauseContextDetail, + useResumeContext, + useResumeExecutionDetail, +} from '@/hooks/queries/resume-execution' interface NormalizedInputField { id: string @@ -63,62 +58,6 @@ interface ResponseStructureRow { value: any } -interface ResumeQueueEntrySummary { - id: string - contextId: string - status: string - queuedAt: string | null - claimedAt: string | null - completedAt: string | null - failureReason: string | null - newExecutionId: string - resumeInput: any -} - -interface PausePointWithQueue { - contextId: string - triggerBlockId?: string - blockId?: string - response: any - registeredAt: string - resumeStatus: ResumeStatus - snapshotReady: boolean - resumeLinks?: ResumeLinks - queuePosition?: number | null - latestResumeEntry?: ResumeQueueEntrySummary | null - parallelScope?: any - loopScope?: any - pauseKind?: 'human' | 'time' - resumeAt?: string -} - -interface PausedExecutionSummary { - id: string - workflowId: string - executionId: string - status: string - totalPauseCount: number - resumedCount: number - pausedAt: string | null - updatedAt: string | null - expiresAt: string | null - metadata: Record | null - triggerIds: string[] - pausePoints: PausePointWithQueue[] -} - -interface PauseContextDetail { - execution: PausedExecutionSummary - pausePoint: PausePointWithQueue - queue: ResumeQueueEntrySummary[] - activeResumeEntry?: ResumeQueueEntrySummary | null -} - -interface PausedExecutionDetail extends PausedExecutionSummary { - executionSnapshot: any - queue: ResumeQueueEntrySummary[] -} - interface ResumeExecutionPageProps { params: { workflowId: string; executionId: string } initialExecutionDetail: PausedExecutionDetail | null @@ -205,10 +144,13 @@ export default function ResumeExecutionPage({ const { workflowId, executionId } = params const router = useRouter() const brandConfig = useBrandConfig() + const queryClient = useQueryClient() - const [executionDetail, setExecutionDetail] = useState( - initialExecutionDetail - ) + const { + data: executionDetail, + isFetching: refreshingExecution, + refetch: refetchExecutionDetail, + } = useResumeExecutionDetail(workflowId, executionId, initialExecutionDetail ?? undefined) const pausePoints = executionDetail?.pausePoints ?? [] const defaultContextId = useMemo(() => { @@ -222,7 +164,11 @@ export default function ResumeExecutionPage({ const [selectedContextId, setSelectedContextId] = useState( defaultContextId ?? null ) - const [selectedDetail, setSelectedDetail] = useState(null) + const { data: selectedDetail, isLoading: loadingDetail } = usePauseContextDetail( + workflowId, + executionId, + selectedContextId ?? undefined + ) const [selectedStatus, setSelectedStatus] = useState('paused') const [queuePosition, setQueuePosition] = useState(undefined) @@ -233,12 +179,12 @@ export default function ResumeExecutionPage({ >({}) const [formValues, setFormValues] = useState>({}) const [formErrors, setFormErrors] = useState>({}) - const [loadingDetail, setLoadingDetail] = useState(false) const [loadingAction, setLoadingAction] = useState(false) - const [refreshingExecution, setRefreshingExecution] = useState(false) const [error, setError] = useState(null) const [message, setMessage] = useState(null) + const resumeMutation = useResumeContext() + const normalizeInputFormatFields = useCallback((raw: any): NormalizedInputField[] => { if (!Array.isArray(raw)) return [] return raw @@ -513,296 +459,190 @@ export default function ResumeExecutionPage({ .filter((row): row is ResponseStructureRow => row !== null) }, [selectedDetail]) + const seedFormFromDetail = useCallback( + (detail: PauseContextDetail) => { + const responseData = detail.pausePoint.response?.data ?? {} + const operation = responseData.operation || 'human' + const fetchedInputFields = normalizeInputFormatFields(responseData.inputFormat) + const submission = + responseData && + typeof responseData.submission === 'object' && + !Array.isArray(responseData.submission) + ? (responseData.submission as Record) + : undefined + if (operation === 'human' && fetchedInputFields.length > 0) { + const baseValues = buildInitialFormValues(fetchedInputFields, submission) + let mergedValues = baseValues + setFormValuesByContext((prev) => { + const existingValues = prev[detail.pausePoint.contextId] + if (existingValues) mergedValues = { ...baseValues, ...existingValues } + return { ...prev, [detail.pausePoint.contextId]: mergedValues } + }) + setFormValues(mergedValues) + setFormErrors({}) + if (resumeInputsRef.current[detail.pausePoint.contextId] !== undefined) { + delete resumeInputsRef.current[detail.pausePoint.contextId] + } + setResumeInput('') + } else { + const initialValue = + typeof responseData === 'string' + ? responseData + : JSON.stringify(responseData ?? {}, null, 2) + if (resumeInputsRef.current[detail.pausePoint.contextId] !== undefined) { + setResumeInput(resumeInputsRef.current[detail.pausePoint.contextId]) + } else { + setResumeInput(initialValue) + resumeInputsRef.current = { + ...resumeInputsRef.current, + [detail.pausePoint.contextId]: initialValue, + } + } + setFormValues({}) + setFormErrors({}) + } + }, + [normalizeInputFormatFields, buildInitialFormValues] + ) + useEffect(() => { + if (!selectedDetail) return + setSelectedStatus(selectedDetail.pausePoint.resumeStatus) + setQueuePosition(selectedDetail.pausePoint.queuePosition) + seedFormFromDetail(selectedDetail) + }, [selectedDetail, seedFormFromDetail]) + + const handleRefreshExecution = useCallback(async () => { + const { data } = await refetchExecutionDetail() if (!selectedContextId) { - setSelectedDetail(null) - return + const firstPaused = + data?.pausePoints.find((point) => point.resumeStatus === 'paused')?.contextId ?? null + setSelectedContextId(firstPaused) } - const controller = new AbortController() - const loadDetail = async () => { - setLoadingDetail(true) - try { - // boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission) - const response = await fetch( - `/api/resume/${workflowId}/${executionId}/${selectedContextId}`, - { - method: 'GET', - credentials: 'include', - cache: 'no-store', - signal: controller.signal, + }, [refetchExecutionDetail, selectedContextId]) + + const handleResume = useCallback( + async () => { + if (!selectedContextId || !selectedDetail) return + setLoadingAction(true) + setError(null) + setMessage(null) + let resumePayload: any + if (isHumanMode && hasInputFormat) { + const errors: Record = {} + const submission: Record = {} + for (const field of inputFormatFields) { + const rawValue = formValues[field.name] ?? '' + const hasValue = + field.type === 'boolean' + ? rawValue === 'true' || rawValue === 'false' + : rawValue.trim().length > 0 && rawValue !== '__unset__' + if (!hasValue || rawValue === '__unset__') { + if (field.required) errors[field.name] = 'This field is required.' + continue } - ) - if (!response.ok) { - setSelectedDetail(null) + const { value, error: parseError } = parseFormValue(field, rawValue) + if (parseError) { + errors[field.name] = parseError + continue + } + if (value !== undefined) submission[field.name] = value + } + if (Object.keys(errors).length > 0) { + setFormErrors(errors) + setLoadingAction(false) return } - const data: PauseContextDetail = await response.json() - setSelectedDetail(data) - setSelectedStatus(data.pausePoint.resumeStatus) - setQueuePosition(data.pausePoint.queuePosition) - const responseData = data.pausePoint.response?.data ?? {} - const operation = responseData.operation || 'human' - const fetchedInputFields = normalizeInputFormatFields(responseData.inputFormat) - const submission = - responseData && - typeof responseData.submission === 'object' && - !Array.isArray(responseData.submission) - ? (responseData.submission as Record) - : undefined - if (operation === 'human' && fetchedInputFields.length > 0) { - const baseValues = buildInitialFormValues(fetchedInputFields, submission) - let mergedValues = baseValues - setFormValuesByContext((prev) => { - const existingValues = prev[data.pausePoint.contextId] - if (existingValues) mergedValues = { ...baseValues, ...existingValues } - return { ...prev, [data.pausePoint.contextId]: mergedValues } - }) - setFormValues(mergedValues) - setFormErrors({}) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - delete resumeInputsRef.current[data.pausePoint.contextId] - } - setResumeInput('') - } else { - const initialValue = - typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData ?? {}, null, 2) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - setResumeInput(resumeInputsRef.current[data.pausePoint.contextId]) - } else { - setResumeInput(initialValue) - resumeInputsRef.current = { - ...resumeInputsRef.current, - [data.pausePoint.contextId]: initialValue, - } + setFormErrors({}) + resumePayload = { submission } + } else { + let parsedInput: any + if (resumeInput && resumeInput.trim().length > 0) { + try { + parsedInput = JSON.parse(resumeInput) + } catch { + setError('Resume input must be valid JSON.') + setLoadingAction(false) + return } - setFormValues({}) - setFormErrors({}) } - } catch (err) { - if ((err as any)?.name !== 'AbortError') { - logger.error('Failed to load pause context detail', err) - } - } finally { - setLoadingDetail(false) + resumePayload = parsedInput } - } - loadDetail() - return () => controller.abort() - }, [ - workflowId, - executionId, - selectedContextId, - normalizeInputFormatFields, - buildInitialFormValues, - ]) - - const refreshExecutionDetail = useCallback(async () => { - setRefreshingExecution(true) - try { - const raw = await requestJson(resumeWorkflowExecutionContract, { - params: { workflowId, executionId }, - }) - // double-cast-allowed: contract pause-point shape is z.record(z.string(), z.unknown()) but the page works against the more specific local PausedExecutionDetail / PausePointWithQueue interfaces - const data = raw as unknown as PausedExecutionDetail - setExecutionDetail(data) - if (!selectedContextId) { - const first = - data.pausePoints?.find((point: PausePointWithQueue) => point.resumeStatus === 'paused') - ?.contextId ?? null - setSelectedContextId(first) - } - } catch (err) { - logger.error('Failed to refresh execution detail', err) - } finally { - setRefreshingExecution(false) - } - }, [workflowId, executionId, selectedContextId]) - - const refreshSelectedDetail = useCallback( - async (contextId: string, showLoader = true) => { try { - if (showLoader) setLoadingDetail(true) - // boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission) - const response = await fetch(`/api/resume/${workflowId}/${executionId}/${contextId}`, { - method: 'GET', - credentials: 'include', - cache: 'no-store', + const { ok, payload } = await resumeMutation.mutateAsync({ + workflowId, + executionId, + contextId: selectedContextId, + input: resumePayload, }) - if (!response.ok) return - const data: PauseContextDetail = await response.json() - setSelectedDetail(data) - setSelectedStatus(data.pausePoint.resumeStatus) - setQueuePosition(data.pausePoint.queuePosition) - const responseData = data.pausePoint.response?.data ?? {} - const operation = responseData.operation || 'human' - const fetchedInputFields = normalizeInputFormatFields(responseData.inputFormat) - const submission = - responseData && - typeof responseData.submission === 'object' && - !Array.isArray(responseData.submission) - ? (responseData.submission as Record) - : undefined - if (operation === 'human' && fetchedInputFields.length > 0) { - const baseValues = buildInitialFormValues(fetchedInputFields, submission) - let mergedValues = baseValues - setFormValuesByContext((prev) => { - const existingValues = prev[data.pausePoint.contextId] - if (existingValues) mergedValues = { ...baseValues, ...existingValues } - return { ...prev, [data.pausePoint.contextId]: mergedValues } - }) - setFormValues(mergedValues) - setFormErrors({}) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - delete resumeInputsRef.current[data.pausePoint.contextId] + if (!ok) { + setError(payload.error || 'Failed to resume execution.') + setSelectedStatus(selectedDetail.pausePoint.resumeStatus) + return + } + const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming' + const nextQueuePosition = payload.queuePosition ?? null + const fallbackContextId = + executionDetail?.pausePoints.find( + (point) => point.contextId !== selectedContextId && point.resumeStatus === 'paused' + )?.contextId ?? null + queryClient.setQueryData( + resumeKeys.execution(workflowId, executionId), + (prev) => { + if (!prev) return prev + return { + ...prev, + pausePoints: prev.pausePoints.map((point) => + point.contextId === selectedContextId + ? { ...point, resumeStatus: nextStatus, queuePosition: nextQueuePosition } + : point + ), + } } - setResumeInput('') - } else { - const initialValue = - typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData ?? {}, null, 2) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - setResumeInput(resumeInputsRef.current[data.pausePoint.contextId]) - } else { - setResumeInput(initialValue) - resumeInputsRef.current = { - ...resumeInputsRef.current, - [data.pausePoint.contextId]: initialValue, + ) + queryClient.setQueryData( + resumeKeys.context(workflowId, executionId, selectedContextId), + (prev) => { + if (!prev || prev.pausePoint.contextId !== selectedContextId) return prev + return { + ...prev, + pausePoint: { + ...prev.pausePoint, + resumeStatus: nextStatus, + queuePosition: nextQueuePosition, + }, } } - setFormValues({}) - setFormErrors({}) - } - } catch (err) { - logger.error('Failed to refresh pause context detail', err) + ) + setSelectedStatus(nextStatus) + setQueuePosition(nextQueuePosition) + setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId)) + setMessage( + payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.' + ) + } catch (err: any) { + setError(err.message || 'Unexpected error while resuming execution.') } finally { - if (showLoader) setLoadingDetail(false) + setLoadingAction(false) } }, - [workflowId, executionId, normalizeInputFormatFields, buildInitialFormValues] + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + workflowId, + executionId, + selectedContextId, + isHumanMode, + hasInputFormat, + inputFormatFields, + formValues, + parseFormValue, + resumeInput, + selectedDetail, + executionDetail, + queryClient, + ] ) - const handleResume = useCallback(async () => { - if (!selectedContextId || !selectedDetail) return - setLoadingAction(true) - setError(null) - setMessage(null) - let resumePayload: any - if (isHumanMode && hasInputFormat) { - const errors: Record = {} - const submission: Record = {} - for (const field of inputFormatFields) { - const rawValue = formValues[field.name] ?? '' - const hasValue = - field.type === 'boolean' - ? rawValue === 'true' || rawValue === 'false' - : rawValue.trim().length > 0 && rawValue !== '__unset__' - if (!hasValue || rawValue === '__unset__') { - if (field.required) errors[field.name] = 'This field is required.' - continue - } - const { value, error: parseError } = parseFormValue(field, rawValue) - if (parseError) { - errors[field.name] = parseError - continue - } - if (value !== undefined) submission[field.name] = value - } - if (Object.keys(errors).length > 0) { - setFormErrors(errors) - setLoadingAction(false) - return - } - setFormErrors({}) - resumePayload = { submission } - } else { - let parsedInput: any - if (resumeInput && resumeInput.trim().length > 0) { - try { - parsedInput = JSON.parse(resumeInput) - } catch { - setError('Resume input must be valid JSON.') - setLoadingAction(false) - return - } - } - resumePayload = parsedInput - } - try { - // boundary-raw-fetch: resume-context POST contract has no body schema (route uses tolerant raw JSON parse for resume input forwarded to PauseResumeManager) - const response = await fetch( - `/api/resume/${workflowId}/${executionId}/${selectedContextId}`, - { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(resumePayload ? { input: resumePayload } : {}), - } - ) - const payload = await response.json() - if (!response.ok) { - setError(payload.error || 'Failed to resume execution.') - setSelectedStatus(selectedDetail.pausePoint.resumeStatus) - return - } - const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming' - const nextQueuePosition = payload.queuePosition ?? null - const fallbackContextId = - executionDetail?.pausePoints.find( - (point) => point.contextId !== selectedContextId && point.resumeStatus === 'paused' - )?.contextId ?? null - setExecutionDetail((prev) => { - if (!prev) return prev - return { - ...prev, - pausePoints: prev.pausePoints.map((point) => - point.contextId === selectedContextId - ? { ...point, resumeStatus: nextStatus, queuePosition: nextQueuePosition } - : point - ), - } - }) - setSelectedDetail((prev) => { - if (!prev || prev.pausePoint.contextId !== selectedContextId) return prev - return { - ...prev, - pausePoint: { - ...prev.pausePoint, - resumeStatus: nextStatus, - queuePosition: nextQueuePosition, - }, - } - }) - setSelectedStatus(nextStatus) - setQueuePosition(nextQueuePosition) - setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId)) - setMessage( - payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.' - ) - await Promise.all([refreshExecutionDetail(), refreshSelectedDetail(selectedContextId, false)]) - } catch (err: any) { - setError(err.message || 'Unexpected error while resuming execution.') - } finally { - setLoadingAction(false) - } - }, [ - workflowId, - executionId, - selectedContextId, - isHumanMode, - hasInputFormat, - inputFormatFields, - formValues, - parseFormValue, - resumeInput, - selectedDetail, - executionDetail, - refreshExecutionDetail, - refreshSelectedDetail, - ]) - const isFormComplete = useMemo(() => { if (!isHumanMode || !hasInputFormat) return true return inputFormatFields.every((field) => { @@ -902,7 +742,7 @@ export default function ResumeExecutionPage({ - {fileError &&

{fileError}

} -
+ - {/* GitHub URL */} -
- - Import from GitHub - +
: 'Fetch'}
- {githubError &&

{githubError}

} -
+
- {/* Paste content */} -
- - Paste SKILL.md Content - - ) => { - setPasteContent(e.target.value) - if (pasteError) setPasteError('') - }} - resizable - className='min-h-[120px] font-mono leading-relaxed' - /> - {pasteError &&

{pasteError}

} -
- - Import - + +
+ ) => { + setPasteContent(e.target.value) + if (pasteError) setPasteError('') + }} + resizable + className='min-h-[120px]' + /> +
+ + Import + +
-
+
) } function ImportDivider() { return ( -
+
or
diff --git a/apps/sim/components/emcn/components/chip-modal/chip-modal.tsx b/apps/sim/components/emcn/components/chip-modal/chip-modal.tsx index d2079583bb6..b15f2031ad1 100644 --- a/apps/sim/components/emcn/components/chip-modal/chip-modal.tsx +++ b/apps/sim/components/emcn/components/chip-modal/chip-modal.tsx @@ -52,6 +52,7 @@ import { ChipTextarea } from '@/components/emcn/components/chip-textarea/chip-te import { Label } from '@/components/emcn/components/label/label' import { Modal, ModalContent } from '@/components/emcn/components/modal/modal' import { TagInput, type TagItem } from '@/components/emcn/components/tag-input/tag-input' +import { Loader } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -377,6 +378,14 @@ interface ChipModalFileFieldProps extends ChipModalFieldBaseProps { * for a single-line zone. */ description?: React.ReactNode + /** + * Renders a spinner inside the drop zone and blocks further picks while an + * async import/upload is in flight. Use for slow selections (zip extraction, + * remote fetches) where the zone would otherwise look idle. Pair with a + * `label` such as `'Importing…'` for an explicit status line. + * @default false + */ + loading?: boolean } export interface ChipModalEmailsFieldProps extends ChipModalFieldBaseProps { @@ -692,6 +701,7 @@ function ChipModalFileControl({ multiple = false, label = 'Drop files here or click to browse', description, + loading = false, disabled, id, 'aria-required': ariaRequired, @@ -700,6 +710,7 @@ function ChipModalFileControl({ }: ChipModalFileFieldProps & { id: string } & React.AriaAttributes) { const inputRef = React.useRef(null) const [isDragging, setIsDragging] = React.useState(false) + const isInteractive = !disabled && !loading const emitFiles = React.useCallback( (files: FileList | null) => { @@ -713,7 +724,8 @@ function ChipModalFileControl({ - - {copied ? 'Copied!' : copyLabel} - - } - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx index d0d65691293..e13c3ce7685 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx @@ -19,7 +19,7 @@ export function UnsavedChangesModal({ open, onOpenChange, onDiscard }: UnsavedCh onOpenChange={onOpenChange} srTitle='Unsaved Changes' title='Unsaved Changes' - description='You have unsaved changes. Are you sure you want to discard them?' + text='You have unsaved changes. Are you sure you want to discard them?' dismissLabel='Keep editing' confirm={{ label: 'Discard Changes', onClick: onDiscard }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts index 0c9b0befe52..3c076b766fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts @@ -1,6 +1,5 @@ export { AddPeopleModal } from './components/add-people-modal' export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field' -export { CopyableValueField } from './components/copyable-value-field' export { CredentialDetailHeading } from './components/credential-detail-heading' export { CredentialDetailLayout } from './components/credential-detail-layout' export { CredentialMembersSection } from './components/credential-members-section' diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index dd30b61d6d1..4c9c20a7d2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -3,6 +3,7 @@ export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { MessageActions } from './message-actions' +export { FloatingOverflowText } from './resource/components/floating-overflow-text' export { ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx index 5328e163c47..5dbb7656374 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx @@ -86,7 +86,7 @@ export function ResourceChromeFallback({ sort={hasSort ? { options: [], active: null, onSort: noop } : undefined} filter={hasFilter ? { content: null } : undefined} /> - {columns ? : null} + {columns ? : null} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 5ca4538f5fd..b544c525cae 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -13,6 +13,7 @@ import { createPortal } from 'react-dom' import { Chip, ChipChevronDown, + chipContentIconClass, chipGeometryClass, chipVariants, DropdownMenu, @@ -178,17 +179,21 @@ export const ResourceHeader = memo(function ResourceHeader({ ) }) ) : ( - - {TitleIcon && } + /** + * Root titles are short static labels ("Tables", "Files"), so the + * span is non-shrinkable and the label never truncates — matching + * the `shrink-0` guarantee the breadcrumb root crumb gets from + * {@link getBreadcrumbSegmentClassName}. Without this, the + * `flex-1` left column collapses during transient initial-load + * layout (the JS-driven `--sidebar-width` settling) and the title + * CSS-truncates to "T…" while the `shrink-0` actions hold width. + */ + + {TitleIcon && } {titleLabel && ( )} @@ -270,7 +275,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ if (editing?.isEditing) { return ( - {Icon && } + {Icon && } - {Icon && } + {Icon && } ) @@ -424,11 +429,11 @@ function BreadcrumbLocationPopover({ className )} > - - + + {rootBreadcrumb?.label && ( @@ -488,6 +493,18 @@ function LocationFocusVeil({ boundaryRef: React.RefObject }) { const [bounds, setBounds] = useState({ top: 0, left: 0 }) + /** + * Portal-mount gate. The veil must render `null` on BOTH the server render + * and the first client (hydration) render — branching on + * `typeof document === 'undefined'` made the two renders diverge, which + * failed hydration and forced React to regenerate the whole page tree on + * the client (a visible header flash during load). + */ + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) useEffect(() => { if (!visible) return @@ -510,7 +527,7 @@ function LocationFocusVeil({ } }, [boundaryRef, visible]) - if (typeof document === 'undefined') return null + if (!mounted) return null return createPortal(
- sortValues?: Record } export interface SelectableConfig { @@ -108,12 +103,19 @@ interface ResourceProps { * - `Resource.Options` — required, the search/filter/sort toolbar * - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …) * - * The shell owns the fixed column layout; the children own their own chrome. + * Invariant: the shell renders identically for every consumer. Consumers supply + * content (columns, rows, cells) and behavior (handlers, configs) only — no + * prop changes the shell's chrome, spacing, or structure. The only sanctioned + * variation is replacing `Resource.Table` with a custom body. + * + * The shell owns the fixed column layout and is the positioning context for + * absolutely-positioned overlays (action bars, slide-out sidebars); the + * children own their own chrome. */ function ResourceRoot({ children, onContextMenu }: ResourceProps) { return (
{children} @@ -124,52 +126,50 @@ function ResourceRoot({ children, onContextMenu }: ResourceProps) { interface ResourceTableProps { columns: ResourceColumn[] rows: ResourceRow[] - defaultSort?: string - sort?: SortConfig selectedRowId?: string | null selectable?: SelectableConfig rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void - isLoading?: boolean onLoadMore?: () => void hasMore?: boolean isLoadingMore?: boolean pagination?: PaginationConfig - emptyMessage?: string + /** + * Sanctioned overlay slot. Rendered absolutely against the table region + * (action bars, slide-out sidebars, drop targets). The overlay owns its own + * chrome and positioning; it never alters the table's rendering. + */ overlay?: ReactNode } /** * Data table body, module-private and exposed only as `Resource.Table` — the * compound member is the sole way consumers render it. + * + * Chrome guarantee: the ``, ``, and column headers render + * unconditionally — no prop or row state (empty, loading, error) ever drops + * them. Structural additions (checkbox column, load-more sentinel, pagination + * bar) are driven purely by which configs the consumer supplies and always + * render the canonical chrome. */ const ResourceTable = memo(function ResourceTable({ columns, rows, - defaultSort, - sort: externalSort, selectedRowId, selectable, rowDragDrop, onRowClick, onRowHover, onRowContextMenu, - isLoading, onLoadMore, hasMore, isLoadingMore, pagination, - emptyMessage, overlay, }: ResourceTableProps) { const loadMoreRef = useRef(null) - const sortEnabled = defaultSort != null - const [internalSort, setInternalSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({ - column: defaultSort ?? '', - direction: 'desc', - }) const [contextMenuRowId, setContextMenuRowId] = useState(null) @@ -201,24 +201,6 @@ const ResourceTable = memo(function ResourceTable({ } }, [contextMenuRowId]) - const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => { - setInternalSort({ column, direction }) - }, []) - - const displayRows = useMemo(() => { - if (!sortEnabled || externalSort) return rows - return [...rows].sort((a, b) => { - const col = internalSort.column - const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? '' - const bVal = b.sortValues?.[col] ?? b.cells[col]?.label ?? '' - const cmp = - typeof aVal === 'number' && typeof bVal === 'number' - ? aVal - bVal - : String(aVal).localeCompare(String(bVal)) - return internalSort.direction === 'asc' ? -cmp : cmp - }) - }, [rows, internalSort, sortEnabled, externalSort]) - useEffect(() => { if (!onLoadMore || !hasMore) return const el = loadMoreRef.current @@ -242,22 +224,9 @@ const ResourceTable = memo(function ResourceTable({ [selectable] ) - /** - * While loading, the table chrome (column headers) renders with an empty body - * and the rows "just load in" — never a skeleton, and never a false - * empty-state (the empty message is gated on `!isLoading`). - */ - if (!isLoading && rows.length === 0 && emptyMessage) { - return ( -
- {emptyMessage} -
- ) - } - return (
-
+
@@ -273,41 +242,18 @@ const ResourceTable = memo(function ResourceTable({ /> )} - {columns.map((col) => { - if (!sortEnabled) { - return ( - - ) - } - const isActive = internalSort.column === col.id - const SortIcon = internalSort.direction === 'asc' ? ArrowUp : ArrowDown - return ( - - ) - })} + {columns.map((col) => ( + + ))} - {displayRows.map((row) => ( + {rows.map((row) => ( - Are you sure you want to delete{' '} - {fileName ? ( - {fileName} - ) : ( - `${totalCount} item${totalCount === 1 ? '' : 's'}` - )} - ? {consequence} - - } + text={[ + 'Are you sure you want to delete ', + fileName + ? { text: fileName, bold: true } + : `${totalCount} item${totalCount === 1 ? '' : 's'}`, + `? ${consequence}`, + ]} confirm={{ label: 'Delete', onClick: onDelete, diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 7796b8d6ff2..b4ff6d2706d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -190,8 +190,7 @@ export function Files() { }, [permissionConfig.hideFilesTab, router, workspaceId]) const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) - const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, isLoading: foldersLoading } = - useWorkspaceFileFolders(workspaceId) + const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const uploadFile = useUploadWorkspaceFile() const deleteFile = useDeleteWorkspaceFile() @@ -437,14 +436,6 @@ export function Files() { owner: ownerCell(folder.userId, members), updated: timeCell(folder.updatedAt), }, - sortValues: { - name: folder.name, - size: folderSizeMap.get(folder.id) ?? -1, - type: 'Folder', - created: new Date(folder.createdAt).getTime(), - updated: new Date(folder.updatedAt).getTime(), - owner: members?.find((m) => m.userId === folder.userId)?.name ?? '', - }, })) const fileRows = filteredFiles.map((file) => { @@ -467,14 +458,6 @@ export function Files() { owner: ownerCell(file.uploadedBy, members), updated: timeCell(file.updatedAt), }, - sortValues: { - name: file.name, - size: file.size, - type: formatFileType(file.type, file.name), - created: new Date(file.uploadedAt).getTime(), - updated: new Date(file.updatedAt).getTime(), - owner: members?.find((m) => m.userId === file.uploadedBy)?.name ?? '', - }, } return row }) @@ -1687,12 +1670,6 @@ export function Files() { const hasActiveFilters = typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0 - const emptyMessage = debouncedSearchTerm - ? `No files match "${debouncedSearchTerm}"` - : hasActiveFilters - ? 'No files match the active filters' - : undefined - const filterContent = useMemo(() => { const typeDisplayLabel = typeFilter.length === 0 @@ -1844,19 +1821,19 @@ export function Files() { if (fileIdFromRoute && !selectedFile && isLoading) { return ( -
+
-
+ ) } if (selectedFile) { return ( <> -
+ -
+ {children} -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx index 0e5bcd3c98d..208a358b975 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx @@ -85,7 +85,7 @@ export function ThinkingBlock({
- Mothership + Sim void + /** Opens the menu anchored at a viewport position (caret or trigger rect). */ + open: (anchor: { left: number; top: number }, options?: { mention?: boolean }) => void close: () => void moveActive: (delta: number) => void selectActive: () => boolean @@ -46,23 +47,27 @@ export interface PlusMenuHandle { /** * Box and typography shared by the textarea and its mirror overlay — both must * produce identical line wrapping so the overlay text sits exactly over the - * (transparent) textarea text. + * (transparent) textarea text. The scale is the canonical chip text-field + * scale ({@link ChipTextarea}: `text-sm`, default tracking), so the editor + * reads identically in the chat input and inside chip modals — one size, + * everywhere. */ const FIELD_MIRROR_CLASSES = cn( - 'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', - 'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]' + 'm-0 box-border min-h-[20px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', + 'px-1 py-1 font-body text-sm leading-[20px]' ) /** * The textarea grows to its full content height (`h-auto`, no internal scroll); * the shared scroller clips and scrolls it. Its text is transparent so the - * mirror overlay shows through; only the caret paints. + * mirror overlay shows through; only the caret paints. The placeholder uses + * the canonical `--text-muted`, matching every other chip text field. */ export const TEXTAREA_BASE_CLASSES = cn( FIELD_MIRROR_CLASSES, 'block h-auto resize-none overflow-hidden', 'text-transparent caret-[var(--text-primary)] outline-none', - 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', + 'placeholder:text-[var(--text-muted)]', 'focus-visible:ring-0 focus-visible:ring-offset-0' ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index 9ea5a44d428..bf00d079201 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -25,6 +25,13 @@ export { DropOverlay } from './drop-overlay' export { MicButton } from './mic-button' export type { AvailableResourceGroup } from './plus-menu-dropdown' export { PlusMenuDropdown } from './plus-menu-dropdown' +export type { + PromptEditorInstance, + PromptEditorKeyPolicy, + PromptEditorProps, + UsePromptEditorProps, +} from './prompt-editor' +export { PromptEditor, usePromptEditor } from './prompt-editor' export { SendButton } from './send-button' export type { SkillsMenuHandle } from './skills-menu-dropdown/skills-menu-dropdown' export { SkillsMenuDropdown } from './skills-menu-dropdown/skills-menu-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 6ca744fceae..530379f1dbb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -10,9 +10,8 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, - Tooltip, } from '@/components/emcn' -import { Plus, Workflow } from '@/components/emcn/icons' +import { Workflow } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { buildFileFolderTree, @@ -57,19 +56,12 @@ export const PlusMenuDropdown = React.memo( const [search, setSearch] = useState('') const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) const [activeIndex, setActiveIndex] = useState(0) - const buttonRef = useRef(null) const searchRef = useRef(null) const contentRef = useRef(null) const doOpen = useCallback( - (anchor?: { left: number; top: number }, options?: { mention?: boolean }) => { - if (anchor) { - setAnchorPos(anchor) - } else { - const rect = buttonRef.current?.getBoundingClientRect() - if (!rect) return - setAnchorPos({ left: rect.left, top: rect.top }) - } + (anchor: { left: number; top: number }, options?: { mention?: boolean }) => { + setAnchorPos(anchor) setIsMention(!!options?.mention) setOpen(true) setSearch('') @@ -252,162 +244,146 @@ export const PlusMenuDropdown = React.memo( } return ( - <> - - -
+ +
+ + + {!isMention && ( + { + setSearch(e.target.value) + setActiveIndex(0) }} + onKeyDown={handleSearchKeyDown} /> - - - {!isMention && ( - { - setSearch(e.target.value) - setActiveIndex(0) - }} - onKeyDown={handleSearchKeyDown} - /> - )} -
- {/* Always-mounted; swapping this subtree with filtered results makes Radix's + )} +
+ {/* Always-mounted; swapping this subtree with filtered results makes Radix's menu FocusScope steal focus from the search input back to the content root. */} - - - - - - - - Add resources - - + {/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's + menu Collection, or FocusScope restores focus to the content root. */} + {filteredItems !== null && + (filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + const isActive = index === activeIndex + return ( + + ) + }) + ) : ( +
+ No results +
+ ))} +
+ + ) }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts new file mode 100644 index 00000000000..6b3e30415db --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts @@ -0,0 +1,7 @@ +export { PromptEditor, type PromptEditorProps } from './prompt-editor' +export { + type PromptEditorInstance, + type PromptEditorKeyPolicy, + type UsePromptEditorProps, + usePromptEditor, +} from './use-prompt-editor' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx new file mode 100644 index 00000000000..b2633d28841 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react' +import { cn } from '@/lib/core/utils/cn' +import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import { + OVERLAY_CLASSES, + SCROLLER_CLASSES, + TEXTAREA_BASE_CLASSES, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' +import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown' +import type { + PromptEditorInstance, + PromptEditorKeyPolicy, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import { SkillsMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown' +import { + computeMentionHighlightRanges, + extractContextTokens, + stripMentionTrigger, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' + +export interface PromptEditorProps extends PromptEditorKeyPolicy { + /** Editor instance from {@link usePromptEditor}. */ + editor: PromptEditorInstance + /** Placeholder shown while the editor is empty. */ + placeholder?: string + /** Focuses the editor (caret at end) on mount. */ + autoFocus?: boolean + /** + * Layout/sizing only — a height cap (`max-h-[200px]`) or fill (`flex-1`) + * for the scroll container. The text chrome is owned by the editor. + */ + className?: string + /** Accessible label for the textarea. */ + 'aria-label'?: string +} + +/** + * The rendered face of {@link usePromptEditor}: a transparent-text textarea + * under a mirror overlay that paints mention chips (icon + label) in place of + * their tokens, plus the caret-anchored `@`-resource and `/`-skill menus. The + * textarea grows to its content height inside a single scroller, so overlay + * and caret co-scroll natively and never drift. + * + * Everything intrinsic to editing lives here; host-specific keys (Enter + * submit, ArrowUp history) are threaded in via {@link PromptEditorKeyPolicy}. + * + * @example + * ```tsx + * const editor = usePromptEditor({ workspaceId }) + * + * ``` + */ +export function PromptEditor({ + editor, + placeholder, + autoFocus = false, + className, + 'aria-label': ariaLabel, + onSubmit, + onArrowUpOnEmpty, +}: PromptEditorProps) { + const { textareaRef, value } = editor + + useLayoutEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + // Grow the textarea to its full content height; the scroller caps the + // visible height and scrolls textarea + overlay together natively. + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + }, [value, textareaRef]) + + useEffect(() => { + if (autoFocus) editor.focusAtEnd() + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only focus + }, []) + + /** + * Clicking the editor's empty regions (padding, space below the last line) + * focuses the textarea; clicks on the textarea itself keep native caret + * placement. + */ + const handleSurfaceClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === textareaRef.current) return + if ((e.target as HTMLElement).closest('button')) return + textareaRef.current?.focus() + }, + [textareaRef] + ) + + const overlayContent = useMemo(() => { + const contexts = editor.contexts + + if (!value) { + return {'\u00A0'} + } + + if (contexts.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const tokens = extractContextTokens(contexts) + const ranges = computeMentionHighlightRanges(value, tokens) + + if (ranges.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const elements: React.ReactNode[] = [] + let lastIndex = 0 + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i] + + if (range.start > lastIndex) { + const before = value.slice(lastIndex, range.start) + elements.push({before}) + } + + const mentionLabel = stripMentionTrigger(range.token) + const matchingCtx = contexts.find((c) => c.label === mentionLabel) + + const mentionIconNode = matchingCtx ? ( + + ) : null + + elements.push( + + + {/* Invisible trigger glyph keeps the overlay's advance identical to + the transparent textarea; the icon centers over its slot. */} + {range.token.charAt(0)} + {mentionIconNode} + + {mentionLabel} + + ) + lastIndex = range.end + } + + const tail = value.slice(lastIndex) + if (tail) { + const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail + elements.push({displayTail}) + } + + return elements.length > 0 ? elements : {'\u00A0'} + }, [value, editor.contexts]) + + return ( +
+ {/* Sizer for textarea + overlay: the textarea grows to full content + height and the overlay fills it via `inset-0`, so both are flow + children of the same scroller and co-scroll natively. */} +
+ + +
- {col.header} - - - + {col.header} +