fix: approval page freeze, stale state, and missing approval link (#8653)

* fix: prevent browser freeze when approval form number field has no default value

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: disable approval buttons and keep polling after approve/deny action

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: restore approval page link and prevent double resume in flow viewer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: guard against NaN fallback in Range and reset actionTaken on new approval step

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix approval page url

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-04-01 05:25:22 +02:00
committed by GitHub
co-authored by Claude Opus 4.5
parent df7a8eebcf
commit 7069202190
3 changed files with 50 additions and 24 deletions
@@ -3,7 +3,7 @@
import { type Job, JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { X } from 'lucide-svelte'
import { ExternalLink, X } from 'lucide-svelte'
import DisplayResult from './DisplayResult.svelte'
import Tooltip from './Tooltip.svelte'
import { Button } from './common'
@@ -23,6 +23,7 @@
let default_payload: object = $state({})
let description: any = $state(undefined)
let hide_cancel = $state(false)
let approvalPageUrl: string | undefined = $state(undefined)
let defaultValues = $state({})
@@ -47,6 +48,8 @@
defaultValues = JSON.parse(JSON.stringify(args))
default_payload = args
approvalPageUrl = job_result?.['approvalPage']
actionTaken = false
hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false
schema = mergeSchema(
job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {},
@@ -55,6 +58,7 @@
}
let loading = $state(false)
let actionTaken = $state(false)
async function continu(approve: boolean) {
loading = true
try {
@@ -66,6 +70,7 @@
approved: approve
}
})
actionTaken = true
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed', true)
} finally {
@@ -84,7 +89,7 @@
<div class="mt-2"></div>
{/if}
<div>
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button
@@ -92,7 +97,7 @@
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={loading}
disabled={loading || actionTaken}
destructive
unifiedSize="md"
on:click={() => continu(false)}
@@ -100,12 +105,28 @@
</div>
{/if}
<div>
<Button variant="accent" onClick={() => continu(true)} disabled={loading} unifiedSize="md">
<Button
variant="accent"
onClick={() => continu(true)}
disabled={loading || actionTaken}
unifiedSize="md"
>
Resume
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
</Button>
</div>
{#if approvalPageUrl}
<a
href={approvalPageUrl}
target="_blank"
rel="noreferrer"
class="text-accent flex items-center gap-1 whitespace-nowrap"
>
Approval page <ExternalLink size={12} />
</a>
{/if}
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
+9 -3
View File
@@ -19,7 +19,7 @@
min = 0,
max = 100,
initialValue = 0,
value = $bindable(typeof initialValue === 'string' ? parseInt(initialValue) : initialValue),
value = $bindable(),
disabled = false,
defaultValue = undefined,
format = (v) => `${v}`,
@@ -36,8 +36,14 @@
}
run(() => {
if (value === null) {
value = 0
if (value === null || value === undefined || Number.isNaN(value)) {
const fallback =
initialValue !== undefined
? typeof initialValue === 'string'
? parseInt(initialValue)
: initialValue
: (min ?? 0)
value = Number.isNaN(fallback) ? (min ?? 0) : fallback
}
})
@@ -33,6 +33,7 @@
let default_payload: any = $state({})
let loading = $state(false)
let valid = $state(true)
let actionTaken: 'approved' | 'denied' | undefined = $state(undefined)
let pollInterval: number | undefined = undefined
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
@@ -81,6 +82,9 @@
id: page.params.job ?? ''
})) as Job
completed = job?.type === 'CompletedJob'
if (completed) {
pollInterval && clearInterval(pollInterval)
}
} catch {
// Job details are optional — page works with just approvalInfo
}
@@ -103,7 +107,7 @@
}
})
sendUserToast('Flow approved')
pollInterval && clearInterval(pollInterval)
actionTaken = 'approved'
loadData()
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
@@ -125,7 +129,7 @@
}
})
sendUserToast('Flow denied!')
pollInterval && clearInterval(pollInterval)
actionTaken = 'denied'
loadData()
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
@@ -259,6 +263,12 @@
<Alert type="info" title="Flow completed">
The flow is not running anymore. You cannot cancel or resume it.
</Alert>
{:else if actionTaken}
<Alert type="info" title={actionTaken === 'approved' ? 'Flow approved' : 'Flow denied'}>
{actionTaken === 'approved'
? 'You have approved this flow. Waiting for it to complete...'
: 'You have denied this flow. Waiting for it to complete...'}
</Alert>
{/if}
{#if approvalInfo.description != undefined}
@@ -279,31 +289,20 @@
{/if}
{/if}
{#if !completed && approvalInfo.can_approve}
{#if !completed && !actionTaken && approvalInfo.can_approve}
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap">
{#if approvalInfo.hide_cancel !== true}
<Button
variant="accent"
destructive
onclick={cancel}
size="lg"
disabled={completed || loading}
>
<Button variant="accent" destructive onclick={cancel} size="lg" disabled={loading}>
Deny
</Button>
{:else}
<div></div>
{/if}
<Button
variant="accent"
onclick={resume}
size="lg"
disabled={completed || !valid || loading}
>
<Button variant="accent" onclick={resume} size="lg" disabled={!valid || loading}>
Approve
</Button>
</div>
{:else if !completed && !approvalInfo.can_approve}
{:else if !completed && !actionTaken && !approvalInfo.can_approve}
{#if approvalInfo.user_auth_required && !$userStore}
<Login {rd} />
{:else}