Skip to content

Commit 4085a1e

Browse files
authored
fix(resume): fix click-blocking footer overlay + hardening on HITL resume page (#5381)
* 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 * 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. * 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.
1 parent 577a7a2 commit 4085a1e

7 files changed

Lines changed: 218 additions & 46 deletions

File tree

apps/sim/app/(auth)/components/support-footer.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@ import { cn } from '@sim/emcn'
44
import { useBrandConfig } from '@/ee/whitelabeling'
55

66
export interface SupportFooterProps {
7-
position?: 'fixed' | 'absolute'
7+
/**
8+
* `fixed`/`absolute` pin the footer over the page (short, centered forms
9+
* only — content must never render underneath it). `static` renders it in
10+
* normal document flow after the content, which is required for pages with
11+
* unbounded content height (e.g. the resume gate's HITL form): an
12+
* absolutely-positioned footer with no reserved space is not pushed down by
13+
* flow content, so it silently overlaps and eats clicks on whatever content
14+
* ends up in its footprint.
15+
*/
16+
position?: 'fixed' | 'absolute' | 'static'
817
}
918

1019
export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
@@ -13,7 +22,8 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
1322
return (
1423
<div
1524
className={cn(
16-
'right-0 bottom-0 left-0 z-50 pb-8 text-center text-[var(--text-muted)] text-caption leading-relaxed',
25+
'pb-8 text-center text-[var(--text-muted)] text-caption leading-relaxed',
26+
position !== 'static' && 'right-0 bottom-0 left-0 z-50',
1727
position
1828
)}
1929
>

apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ interface InterfacesShellProps {
1818
}
1919

2020
export function InterfacesShell({ children }: InterfacesShellProps) {
21-
return <LogoShell footer={<SupportFooter position='absolute' />}>{children}</LogoShell>
21+
return <LogoShell footer={<SupportFooter position='static' />}>{children}</LogoShell>
2222
}

apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx

Lines changed: 73 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -103,28 +103,51 @@ function getBlockNameFromSnapshot(
103103
}
104104
}
105105

106+
const DISPLAY_VALUE_PREVIEW_MAX_CHARS = 5000
107+
108+
function truncateForPreview(text: string): { text: string; truncated: boolean } {
109+
if (text.length <= DISPLAY_VALUE_PREVIEW_MAX_CHARS) return { text, truncated: false }
110+
return { text: text.slice(0, DISPLAY_VALUE_PREVIEW_MAX_CHARS), truncated: true }
111+
}
112+
106113
function renderStructuredValuePreview(value: unknown) {
107-
if (value === null || value === undefined) {
114+
if (value === null || value === undefined || value === '') {
108115
return <span className='text-[12px] text-[var(--text-muted)]'></span>
109116
}
110117

111118
if (typeof value === 'object') {
119+
const prettyPrinted = JSON.stringify(value, null, 2)
120+
const { text, truncated } = truncateForPreview(prettyPrinted)
112121
return (
113122
<div className='min-w-[220px]'>
114123
<Code.Viewer
115-
code={JSON.stringify(value, null, 2)}
124+
code={truncated ? `${text}\n…` : text}
116125
language='json'
117126
wrapText
118127
className='max-h-[220px]'
119128
/>
129+
{truncated && (
130+
<p className='mt-1 text-[11px] text-[var(--text-muted)]'>
131+
Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '}
132+
{prettyPrinted.length.toLocaleString()} characters shown).
133+
</p>
134+
)}
120135
</div>
121136
)
122137
}
123138

124-
const stringValue = String(value)
139+
const { text: stringValue, truncated } = truncateForPreview(String(value))
125140
return (
126-
<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]'>
127-
{stringValue}
141+
<div className='max-w-full'>
142+
<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]'>
143+
{truncated ? `${stringValue}…` : stringValue}
144+
</div>
145+
{truncated && (
146+
<p className='mt-1 text-[11px] text-[var(--text-muted)]'>
147+
Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '}
148+
{String(value).length.toLocaleString()} characters shown).
149+
</p>
150+
)}
128151
</div>
129152
)
130153
}
@@ -516,50 +539,60 @@ export default function ResumeExecutionPage({
516539

517540
const handleResume = useCallback(
518541
async () => {
519-
if (!selectedContextId || !selectedDetail) return
542+
if (!selectedContextId || !selectedDetail) {
543+
setError('No pause point is selected. Refresh and try again.')
544+
return
545+
}
520546
setLoadingAction(true)
521547
setError(null)
522548
setMessage(null)
523549
let resumePayload: any
524-
if (isHumanMode && hasInputFormat) {
525-
const errors: Record<string, string> = {}
526-
const submission: Record<string, any> = {}
527-
for (const field of inputFormatFields) {
528-
const rawValue = formValues[field.name] ?? ''
529-
const hasValue =
530-
field.type === 'boolean'
531-
? rawValue === 'true' || rawValue === 'false'
532-
: rawValue.trim().length > 0 && rawValue !== '__unset__'
533-
if (!hasValue || rawValue === '__unset__') {
534-
if (field.required) errors[field.name] = 'This field is required.'
535-
continue
536-
}
537-
const { value, error: parseError } = parseFormValue(field, rawValue)
538-
if (parseError) {
539-
errors[field.name] = parseError
540-
continue
550+
try {
551+
if (isHumanMode && hasInputFormat) {
552+
const errors: Record<string, string> = {}
553+
const submission: Record<string, any> = {}
554+
for (const field of inputFormatFields) {
555+
const rawValue = formValues[field.name] ?? ''
556+
const hasValue =
557+
field.type === 'boolean'
558+
? rawValue === 'true' || rawValue === 'false'
559+
: rawValue.trim().length > 0 && rawValue !== '__unset__'
560+
if (!hasValue || rawValue === '__unset__') {
561+
if (field.required) errors[field.name] = 'This field is required.'
562+
continue
563+
}
564+
const { value, error: parseError } = parseFormValue(field, rawValue)
565+
if (parseError) {
566+
errors[field.name] = parseError
567+
continue
568+
}
569+
if (value !== undefined) submission[field.name] = value
541570
}
542-
if (value !== undefined) submission[field.name] = value
543-
}
544-
if (Object.keys(errors).length > 0) {
545-
setFormErrors(errors)
546-
setLoadingAction(false)
547-
return
548-
}
549-
setFormErrors({})
550-
resumePayload = { submission }
551-
} else {
552-
let parsedInput: any
553-
if (resumeInput && resumeInput.trim().length > 0) {
554-
try {
555-
parsedInput = JSON.parse(resumeInput)
556-
} catch {
557-
setError('Resume input must be valid JSON.')
571+
if (Object.keys(errors).length > 0) {
572+
setFormErrors(errors)
573+
setError('Fix the highlighted fields before resuming.')
558574
setLoadingAction(false)
559575
return
560576
}
577+
setFormErrors({})
578+
resumePayload = { submission }
579+
} else {
580+
let parsedInput: any
581+
if (resumeInput && resumeInput.trim().length > 0) {
582+
try {
583+
parsedInput = JSON.parse(resumeInput)
584+
} catch {
585+
setError('Resume input must be valid JSON.')
586+
setLoadingAction(false)
587+
return
588+
}
589+
}
590+
resumePayload = parsedInput
561591
}
562-
resumePayload = parsedInput
592+
} catch (err: any) {
593+
setError(err?.message || 'Failed to prepare resume payload.')
594+
setLoadingAction(false)
595+
return
563596
}
564597
try {
565598
const { ok, payload } = await resumeMutation.mutateAsync({

apps/sim/app/f/[token]/public-file-auth-shell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface PublicFileAuthShellProps {
1515
*/
1616
export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) {
1717
return (
18-
<LogoShell center footer={<SupportFooter position='absolute' />}>
18+
<LogoShell center footer={<SupportFooter position='static' />}>
1919
<div className='flex w-full max-w-lg flex-col items-center justify-center px-4'>
2020
<div className='space-y-1 text-center'>
2121
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>

apps/sim/app/invite/components/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ interface InviteLayoutProps {
1010
* so the invite-to-workspace flow is visually aligned with the rest of auth.
1111
*/
1212
export default function InviteLayout({ children }: InviteLayoutProps) {
13-
return <AuthShell footer={<SupportFooter position='absolute' />}>{children}</AuthShell>
13+
return <AuthShell footer={<SupportFooter position='static' />}>{children}</AuthShell>
1414
}

apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,125 @@ describe('updateResumeOutputInAggregationBuffers', () => {
152152
})
153153
})
154154

155+
describe('PauseResumeManager.getPauseContextDetail', () => {
156+
beforeEach(() => {
157+
vi.clearAllMocks()
158+
resetDbChainMock()
159+
})
160+
161+
it('does not duplicate a pause point large response payload between pausePoint and execution.pausePoints', async () => {
162+
const largeDisplayValue = 'x'.repeat(50_000)
163+
164+
const row = {
165+
id: 'paused-exec-1',
166+
workflowId: 'workflow-1',
167+
executionId: 'execution-1',
168+
status: 'paused',
169+
pausedAt: null,
170+
updatedAt: null,
171+
expiresAt: null,
172+
metadata: {},
173+
executionSnapshot: { triggerIds: [] },
174+
pausePoints: {
175+
'ctx-1': {
176+
contextId: 'ctx-1',
177+
blockId: 'hitl-1',
178+
resumeStatus: 'paused',
179+
snapshotReady: true,
180+
pauseKind: 'human',
181+
registeredAt: '2026-07-02T00:00:00.000Z',
182+
response: {
183+
data: {
184+
operation: 'human',
185+
inputFormat: [{ id: 'field_0', name: 'approved', type: 'boolean', required: false }],
186+
submission: null,
187+
responseStructure: [
188+
{ name: 'ai_analysis', type: 'string', value: largeDisplayValue },
189+
],
190+
},
191+
status: 200,
192+
headers: {},
193+
},
194+
},
195+
'ctx-2': {
196+
contextId: 'ctx-2',
197+
blockId: 'hitl-2',
198+
resumeStatus: 'paused',
199+
snapshotReady: true,
200+
pauseKind: 'human',
201+
registeredAt: '2026-07-02T00:00:00.000Z',
202+
response: {
203+
data: { operation: 'human', inputFormat: [], submission: null },
204+
status: 200,
205+
headers: {},
206+
},
207+
},
208+
},
209+
}
210+
211+
dbChainMockFns.limit.mockResolvedValueOnce([row])
212+
dbChainMockFns.orderBy.mockResolvedValueOnce([])
213+
214+
const detail = await PauseResumeManager.getPauseContextDetail({
215+
workflowId: 'workflow-1',
216+
executionId: 'execution-1',
217+
contextId: 'ctx-1',
218+
})
219+
220+
expect(detail).not.toBeNull()
221+
// The requested pause point keeps its full response payload.
222+
expect(detail!.pausePoint.response.data.responseStructure[0].value).toBe(largeDisplayValue)
223+
expect(detail!.pausePoint.contextId).toBe('ctx-1')
224+
225+
// `execution.pausePoints` must not re-embed the (potentially large)
226+
// response payload — it's already available via `pausePoint` above.
227+
for (const point of detail!.execution.pausePoints) {
228+
expect(point.response?.data).toBeUndefined()
229+
}
230+
// Non-payload fields are still present on the execution's pause points.
231+
expect(detail!.execution.pausePoints.map((p) => p.contextId).sort()).toEqual(['ctx-1', 'ctx-2'])
232+
expect(detail!.execution.pausePoints.find((p) => p.contextId === 'ctx-1')?.resumeStatus).toBe(
233+
'paused'
234+
)
235+
})
236+
237+
it('returns null when the pause context no longer exists', async () => {
238+
dbChainMockFns.limit.mockResolvedValueOnce([
239+
{
240+
id: 'paused-exec-1',
241+
workflowId: 'workflow-1',
242+
executionId: 'execution-1',
243+
status: 'paused',
244+
pausedAt: null,
245+
updatedAt: null,
246+
expiresAt: null,
247+
metadata: {},
248+
executionSnapshot: { triggerIds: [] },
249+
pausePoints: {
250+
'ctx-1': {
251+
contextId: 'ctx-1',
252+
blockId: 'hitl-1',
253+
resumeStatus: 'paused',
254+
snapshotReady: true,
255+
pauseKind: 'human',
256+
registeredAt: '2026-07-02T00:00:00.000Z',
257+
response: { data: { operation: 'human' }, status: 200, headers: {} },
258+
},
259+
},
260+
},
261+
])
262+
dbChainMockFns.orderBy.mockResolvedValueOnce([])
263+
264+
const detail = await PauseResumeManager.getPauseContextDetail({
265+
workflowId: 'workflow-1',
266+
executionId: 'execution-1',
267+
contextId: 'missing-ctx',
268+
})
269+
270+
expect(detail).toBeNull()
271+
})
272+
})
273+
155274
describe('PauseResumeManager.persistPauseResult metadata merge on re-pause', () => {
156275
beforeEach(() => {
157276
vi.clearAllMocks()

apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2048,8 +2048,18 @@ export class PauseResumeManager {
20482048
entry.contextId === contextId && (entry.status === 'claimed' || entry.status === 'pending')
20492049
)
20502050

2051+
// The selected pause point's full `response.data` is already returned via
2052+
// `pausePoint` below; strip it from `execution.pausePoints` so a large
2053+
// HITL display payload isn't duplicated in full within the same response.
2054+
const execution: PausedExecutionDetail = {
2055+
...detail,
2056+
pausePoints: detail.pausePoints.map((point) =>
2057+
point.response ? { ...point, response: { ...point.response, data: undefined } } : point
2058+
),
2059+
}
2060+
20512061
return {
2052-
execution: detail,
2062+
execution,
20532063
pausePoint,
20542064
queue: detail.queue,
20552065
activeResumeEntry,

0 commit comments

Comments
 (0)