Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/sim/app/(auth)/components/support-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -13,7 +22,8 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
return (
<div
className={cn(
'right-0 bottom-0 left-0 z-50 pb-8 text-center text-[var(--text-muted)] text-caption leading-relaxed',
'pb-8 text-center text-[var(--text-muted)] text-caption leading-relaxed',
position !== 'static' && 'right-0 bottom-0 left-0 z-50',
position
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ interface InterfacesShellProps {
}

export function InterfacesShell({ children }: InterfacesShellProps) {
return <LogoShell footer={<SupportFooter position='absolute' />}>{children}</LogoShell>
return <LogoShell footer={<SupportFooter position='static' />}>{children}</LogoShell>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span className='text-[12px] text-[var(--text-muted)]'>—</span>
}

if (typeof value === 'object') {
const prettyPrinted = JSON.stringify(value, null, 2)
const { text, truncated } = truncateForPreview(prettyPrinted)
return (
<div className='min-w-[220px]'>
<Code.Viewer
code={JSON.stringify(value, null, 2)}
code={truncated ? `${text}\n…` : text}
language='json'
wrapText
className='max-h-[220px]'
/>
{truncated && (
<p className='mt-1 text-[11px] text-[var(--text-muted)]'>
Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '}
{prettyPrinted.length.toLocaleString()} characters shown).
</p>
)}
</div>
)
}

const stringValue = String(value)
const { text: stringValue, truncated } = truncateForPreview(String(value))
Comment thread
waleedlatif1 marked this conversation as resolved.
return (
<div className='inline-flex max-w-full rounded-[6px] border border-[var(--border)] bg-[var(--surface-5)] px-2 py-1 font-mono text-[12px] text-[var(--text-primary)] leading-4 [white-space:pre-wrap] [word-break:break-word]'>
{stringValue}
<div className='max-w-full'>
<div className='inline-flex max-w-full rounded-[6px] border border-[var(--border)] bg-[var(--surface-5)] px-2 py-1 font-mono text-[12px] text-[var(--text-primary)] leading-4 [white-space:pre-wrap] [word-break:break-word]'>
{truncated ? `${stringValue}…` : stringValue}
</div>
{truncated && (
<p className='mt-1 text-[11px] text-[var(--text-muted)]'>
Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '}
{String(value).length.toLocaleString()} characters shown).
</p>
)}
</div>
)
}
Expand Down Expand Up @@ -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<string, string> = {}
const submission: Record<string, any> = {}
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<string, string> = {}
const submission: Record<string, any> = {}
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({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/f/[token]/public-file-auth-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface PublicFileAuthShellProps {
*/
export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) {
return (
<LogoShell center footer={<SupportFooter position='absolute' />}>
<LogoShell center footer={<SupportFooter position='static' />}>
<div className='flex w-full max-w-lg flex-col items-center justify-center px-4'>
<div className='space-y-1 text-center'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/invite/components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <AuthShell footer={<SupportFooter position='absolute' />}>{children}</AuthShell>
return <AuthShell footer={<SupportFooter position='static' />}>{children}</AuthShell>
}
119 changes: 119 additions & 0 deletions apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 11 additions & 1 deletion apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading