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..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
@@ -103,28 +103,51 @@ 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) {
+ if (value === null || value === undefined || value === '') {
return
—
}
if (typeof value === 'object') {
+ const prettyPrinted = JSON.stringify(value, null, 2)
+ const { text, truncated } = truncateForPreview(prettyPrinted)
return (
+ {truncated && (
+
+ Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '}
+ {prettyPrinted.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 +539,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,