From 66c7a10aaceef702c213a01d5457962966ee9d07 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 13:24:37 -0700 Subject: [PATCH 1/3] fix(resume): fix click-blocking footer overlay + hardening on HITL resume page - SupportFooter rendered position:absolute with no space reserved for it in InterfacesShell/AuthShell/file-share auth gate, silently overlapping and eating clicks on whatever content ended up in its ~50px footprint (on the resume page, the Resume Execution button itself) - Applied the same fix to /invite and /f/[token], which shared the identical absolute-positioning pattern - handleResume no longer fails silently on validation errors or unexpected exceptions - getPauseContextDetail no longer duplicates a pause point's full response payload in the same API response - Oversized HITL display-data values are now truncated in the resume page preview instead of rendering unbounded --- .../app/(auth)/components/support-footer.tsx | 14 ++- .../interfaces-shell/interfaces-shell.tsx | 2 +- .../[executionId]/resume-page-client.tsx | 110 ++++++++++------ .../app/f/[token]/public-file-auth-shell.tsx | 2 +- apps/sim/app/invite/components/layout.tsx | 2 +- .../human-in-the-loop-manager.test.ts | 119 ++++++++++++++++++ .../executor/human-in-the-loop-manager.ts | 12 +- 7 files changed, 216 insertions(+), 45 deletions(-) diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx index 29ea677ea1b..6b21046388d 100644 --- a/apps/sim/app/(auth)/components/support-footer.tsx +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -4,7 +4,16 @@ import { cn } from '@sim/emcn' import { useBrandConfig } from '@/ee/whitelabeling' export interface SupportFooterProps { - position?: 'fixed' | 'absolute' + /** + * `fixed`/`absolute` pin the footer over the page (short, centered forms + * only — content must never render underneath it). `static` renders it in + * normal document flow after the content, which is required for pages with + * unbounded content height (e.g. the resume gate's HITL form): an + * absolutely-positioned footer with no reserved space is not pushed down by + * flow content, so it silently overlaps and eats clicks on whatever content + * ends up in its footprint. + */ + position?: 'fixed' | 'absolute' | 'static' } export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { @@ -13,7 +22,8 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { return (
diff --git a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx index 7f41bd212d7..c3ea3a614c3 100644 --- a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx +++ b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx @@ -18,5 +18,5 @@ interface InterfacesShellProps { } export function InterfacesShell({ children }: InterfacesShellProps) { - return }>{children} + return }>{children} } diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index f148c358fd5..7cb2f84f71a 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -103,28 +103,50 @@ function getBlockNameFromSnapshot( } } +const DISPLAY_VALUE_PREVIEW_MAX_CHARS = 5000 + +function truncateForPreview(text: string): { text: string; truncated: boolean } { + if (text.length <= DISPLAY_VALUE_PREVIEW_MAX_CHARS) return { text, truncated: false } + return { text: text.slice(0, DISPLAY_VALUE_PREVIEW_MAX_CHARS), truncated: true } +} + function renderStructuredValuePreview(value: unknown) { if (value === null || value === undefined) { return } if (typeof value === 'object') { + const { text, truncated } = truncateForPreview(JSON.stringify(value, null, 2)) return (
+ {truncated && ( +

+ Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '} + {JSON.stringify(value).length.toLocaleString()} characters shown). +

+ )}
) } - const stringValue = String(value) + const { text: stringValue, truncated } = truncateForPreview(String(value)) return ( -
- {stringValue} +
+
+ {truncated ? `${stringValue}…` : stringValue} +
+ {truncated && ( +

+ Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '} + {String(value).length.toLocaleString()} characters shown). +

+ )}
) } @@ -516,50 +538,60 @@ export default function ResumeExecutionPage({ const handleResume = useCallback( async () => { - if (!selectedContextId || !selectedDetail) return + if (!selectedContextId || !selectedDetail) { + setError('No pause point is selected. Refresh and try again.') + 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 + try { + 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 (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.') + if (Object.keys(errors).length > 0) { + setFormErrors(errors) + setError('Fix the highlighted fields before resuming.') 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 } - resumePayload = parsedInput + } catch (err: any) { + setError(err?.message || 'Failed to prepare resume payload.') + setLoadingAction(false) + return } try { const { ok, payload } = await resumeMutation.mutateAsync({ diff --git a/apps/sim/app/f/[token]/public-file-auth-shell.tsx b/apps/sim/app/f/[token]/public-file-auth-shell.tsx index bc4e18c3f9d..5adb3ae8fd1 100644 --- a/apps/sim/app/f/[token]/public-file-auth-shell.tsx +++ b/apps/sim/app/f/[token]/public-file-auth-shell.tsx @@ -15,7 +15,7 @@ interface PublicFileAuthShellProps { */ export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) { return ( - }> + }>

diff --git a/apps/sim/app/invite/components/layout.tsx b/apps/sim/app/invite/components/layout.tsx index 8f7c0bb2fa0..614e7c69356 100644 --- a/apps/sim/app/invite/components/layout.tsx +++ b/apps/sim/app/invite/components/layout.tsx @@ -10,5 +10,5 @@ interface InviteLayoutProps { * so the invite-to-workspace flow is visually aligned with the rest of auth. */ export default function InviteLayout({ children }: InviteLayoutProps) { - return }>{children} + return }>{children} } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 15de7f9f066..1c2d7cfed18 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -152,6 +152,125 @@ describe('updateResumeOutputInAggregationBuffers', () => { }) }) +describe('PauseResumeManager.getPauseContextDetail', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('does not duplicate a pause point large response payload between pausePoint and execution.pausePoints', async () => { + const largeDisplayValue = 'x'.repeat(50_000) + + const row = { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausedAt: null, + updatedAt: null, + expiresAt: null, + metadata: {}, + executionSnapshot: { triggerIds: [] }, + pausePoints: { + 'ctx-1': { + contextId: 'ctx-1', + blockId: 'hitl-1', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { + data: { + operation: 'human', + inputFormat: [{ id: 'field_0', name: 'approved', type: 'boolean', required: false }], + submission: null, + responseStructure: [ + { name: 'ai_analysis', type: 'string', value: largeDisplayValue }, + ], + }, + status: 200, + headers: {}, + }, + }, + 'ctx-2': { + contextId: 'ctx-2', + blockId: 'hitl-2', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { + data: { operation: 'human', inputFormat: [], submission: null }, + status: 200, + headers: {}, + }, + }, + }, + } + + dbChainMockFns.limit.mockResolvedValueOnce([row]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const detail = await PauseResumeManager.getPauseContextDetail({ + workflowId: 'workflow-1', + executionId: 'execution-1', + contextId: 'ctx-1', + }) + + expect(detail).not.toBeNull() + // The requested pause point keeps its full response payload. + expect(detail!.pausePoint.response.data.responseStructure[0].value).toBe(largeDisplayValue) + expect(detail!.pausePoint.contextId).toBe('ctx-1') + + // `execution.pausePoints` must not re-embed the (potentially large) + // response payload — it's already available via `pausePoint` above. + for (const point of detail!.execution.pausePoints) { + expect(point.response?.data).toBeUndefined() + } + // Non-payload fields are still present on the execution's pause points. + expect(detail!.execution.pausePoints.map((p) => p.contextId).sort()).toEqual(['ctx-1', 'ctx-2']) + expect(detail!.execution.pausePoints.find((p) => p.contextId === 'ctx-1')?.resumeStatus).toBe( + 'paused' + ) + }) + + it('returns null when the pause context no longer exists', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausedAt: null, + updatedAt: null, + expiresAt: null, + metadata: {}, + executionSnapshot: { triggerIds: [] }, + pausePoints: { + 'ctx-1': { + contextId: 'ctx-1', + blockId: 'hitl-1', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { data: { operation: 'human' }, status: 200, headers: {} }, + }, + }, + }, + ]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const detail = await PauseResumeManager.getPauseContextDetail({ + workflowId: 'workflow-1', + executionId: 'execution-1', + contextId: 'missing-ctx', + }) + + expect(detail).toBeNull() + }) +}) + describe('PauseResumeManager.persistPauseResult metadata merge on re-pause', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index cc678b88755..a5a5a40d870 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -2048,8 +2048,18 @@ export class PauseResumeManager { entry.contextId === contextId && (entry.status === 'claimed' || entry.status === 'pending') ) + // The selected pause point's full `response.data` is already returned via + // `pausePoint` below; strip it from `execution.pausePoints` so a large + // HITL display payload isn't duplicated in full within the same response. + const execution: PausedExecutionDetail = { + ...detail, + pausePoints: detail.pausePoints.map((point) => + point.response ? { ...point, response: { ...point.response, data: undefined } } : point + ), + } + return { - execution: detail, + execution, pausePoint, queue: detail.queue, activeResumeEntry, From 5c86fae2f1c018ec4d5402bc2a87b291daf85fe0 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 13:49:33 -0700 Subject: [PATCH 2/3] fix(resume): use consistent string for truncation-notice length The object-branch truncation notice sliced from the prettified JSON.stringify(value, null, 2) but reported the total against the compact JSON.stringify(value).length, which could show a nonsensical "5,000 of 4,800 characters shown" when the compact form is shorter than the prettified slice. Derive both from the same string. --- .../resume/[workflowId]/[executionId]/resume-page-client.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index 7cb2f84f71a..106911e7f92 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -116,7 +116,8 @@ function renderStructuredValuePreview(value: unknown) { } if (typeof value === 'object') { - const { text, truncated } = truncateForPreview(JSON.stringify(value, null, 2)) + const prettyPrinted = JSON.stringify(value, null, 2) + const { text, truncated } = truncateForPreview(prettyPrinted) return (
Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '} - {JSON.stringify(value).length.toLocaleString()} characters shown). + {prettyPrinted.length.toLocaleString()} characters shown).

)}
From a672b2691f029658ab30a164c51045b65277937f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 15:10:54 -0700 Subject: [PATCH 3/3] fix(resume): show em dash for empty-string display data values renderStructuredValuePreview only treated null/undefined as empty; an empty string fell through to the plain-text branch and rendered as a bordered, padded, contentless pill that reads as a stray UI element (e.g. an unstyled toggle) in the Display Data table. --- .../resume/[workflowId]/[executionId]/resume-page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index 106911e7f92..e6bf318c428 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -111,7 +111,7 @@ function truncateForPreview(text: string): { text: string; truncated: boolean } } function renderStructuredValuePreview(value: unknown) { - if (value === null || value === undefined) { + if (value === null || value === undefined || value === '') { return }