feat: show restart from failed step in run page top bar (#11317)

* feat: show restart from failed step in run page top bar

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

* fix: keep failed-step restart button when selection is not restartable

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

* feat: add restart link to the failed run status line

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

* fix: use the input error color for the progress bar error state

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

* feat: mention restarting on a fixed flow version in the error line

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

* feat: show a chevron on the restart button when it opens a picker

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

* fix: shrink run page top bar buttons so the bar fits on one line

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

* fix: target the step that failed the run, not a tolerated failure

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

* fix: address review nits on the restart link

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

* fix: skip canceled runs and match the restart link label to its text

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-23 17:37:41 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5b03c1732e
commit 62d0088de5
5 changed files with 133 additions and 45 deletions
@@ -1,7 +1,7 @@
<script lang="ts">
import { Badge, Button } from './common'
import Popover from './meltComponents/Popover.svelte'
import { Play, RefreshCw } from 'lucide-svelte'
import { ChevronDown, Play, RefreshCw } from 'lucide-svelte'
import { FlowService, JobService, type FlowVersion } from '$lib/gen'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -20,6 +20,8 @@
enterpriseOnly?: boolean
variant?: 'default' | 'accent'
unifiedSize?: 'xs' | 'sm' | 'md' | 'lg'
/** `link` renders the trigger as inline text, for use inside a sentence. */
triggerStyle?: 'button' | 'link'
/**
* For nested-step restarts: path of ancestor containers from the top-level
* step down to the leaf. When provided, the LAST entry's step_id is the
@@ -71,6 +73,7 @@
enterpriseOnly = false,
variant = 'default',
unifiedSize = 'md',
triggerStyle = 'button',
nestedPath = undefined,
nestedTopStepId = undefined,
nestedTopBranchOrIterationN = undefined,
@@ -89,6 +92,10 @@
: selectedJobStep
)
const restartTitle = $derived(
`Re-start this flow from step ${displayStepId} (included).${enterpriseOnly ? ' This is a feature only available in enterprise edition.' : ''}`
)
// Sentinel value meaning "use the same version as the original run" (backend receives undefined)
const RUN_VERSION_SENTINEL = -1
@@ -142,8 +149,13 @@
})
return out
})
// A link has no button of its own to restart on click: it always opens the
// popover, whose Restart button then acts as the confirmation.
const needsPopup = $derived(
!!flowPath || iterationFields.length > 0 || selectedJobStepType !== 'single'
triggerStyle === 'link' ||
!!flowPath ||
iterationFields.length > 0 ||
selectedJobStepType !== 'single'
)
let selectedFlowVersion: number = $state(RUN_VERSION_SENTINEL)
let flowVersions: Array<FlowVersion> = $state([])
@@ -244,12 +256,21 @@
</label>
{/snippet}
{#snippet restartTriggerButton(usePlayIcon: boolean)}
{#if triggerStyle === 'link'}
<!-- The popover trigger is already a <button>; nesting another is invalid. -->
<span class="underline hover:no-underline" title={restartTitle}>Restart from this step</span>
{:else}
{@render restartButton(usePlayIcon)}
{/if}
{/snippet}
{#snippet restartButton(usePlayIcon: boolean)}
<Button
title={`Re-start this flow from step ${displayStepId} (included).${enterpriseOnly ? ' This is a feature only available in enterprise edition.' : ''}`}
title={restartTitle}
{variant}
{unifiedSize}
{disabled}
startIcon={{ icon: usePlayIcon ? Play : RefreshCw }}
endIcon={needsPopup ? { icon: ChevronDown } : undefined}
nonCaptureEvent={!usePlayIcon || !!flowPath}
onClick={() => {
if (usePlayIcon && !flowPath) {
@@ -273,6 +294,12 @@
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }}
disablePopup={!needsPopup}
triggerAttrs={{
'aria-label':
triggerStyle === 'link'
? `Restart from this step (${displayStepId})`
: `Re-start from ${displayStepId}`
}}
on:openChange={(e) => {
if (e.detail) {
loadFlowVersions()
@@ -42,7 +42,7 @@
{@render middle?.()}
{/if}
</div>
<div class="flex flex-wrap items-center gap-2 lg:gap-4">
<div class="flex flex-wrap items-center gap-2">
{#if right}
{@render right?.()}
{/if}
@@ -2,6 +2,7 @@
import { type Job } from '$lib/gen'
import ProgressBar from '../progressBar/ProgressBar.svelte'
import { forLater } from '$lib/forLater'
import type { Snippet } from 'svelte'
interface Props {
job?: Job | undefined
@@ -11,6 +12,7 @@
textPosition?: 'top' | 'bottom'
// Prefer step ID over step index when both are available
showStepId?: boolean
errorAction?: Snippet
}
let {
@@ -19,7 +21,8 @@
class: className,
slim = false,
textPosition = 'top',
showStepId = false
showStepId = false,
errorAction
}: Props = $props()
let error: number | undefined = $state(undefined)
@@ -183,4 +186,5 @@
{isCanceled}
{isScheduled}
{isSkipped}
{errorAction}
/>
@@ -4,6 +4,7 @@
import { twMerge } from 'tailwind-merge'
import { Loader2 } from 'lucide-svelte'
import { fade } from 'svelte/transition'
import type { Snippet } from 'svelte'
// Remove padding/margin, border radius and titles
@@ -36,6 +37,8 @@
isScheduled?: boolean
// Whether the job was skipped (early-stop labelled as skipped)
isSkipped?: boolean
/** Rendered after "Error occurred" when the bar is in its error state. */
errorAction?: Snippet
}
let {
@@ -56,7 +59,8 @@
isWaitingForEvents = false,
isCanceled = false,
isScheduled = false,
isSkipped = false
isSkipped = false,
errorAction
}: Props = $props()
let duration = 200
@@ -148,7 +152,7 @@
<div
class="flex justify-between items-end font-medium transition-colors duration-300 ease-in-out {error !=
undefined || isCanceled
? 'text-red-700 dark:text-red-200'
? 'text-red-600 dark:text-red-400'
: 'text-blue-700 dark:text-blue-200'}"
>
<div class={twMerge(slim ? 'text-xs' : 'text-sm', 'flex items-center gap-1')}>
@@ -184,6 +188,9 @@
{/if}
</span>
{/key}
{#if status == 'error' && errorAction}
{@render errorAction()}
{/if}
</div>
<span
class={twMerge(slim ? 'text-xs' : 'text-sm', 'transition-all duration-200 ease-in-out')}
@@ -152,12 +152,41 @@
string,
{ modules: import('$lib/gen').FlowModule[]; groups?: any[] }
> = $state({})
const restart = useNestedRestartState({
const selectedRestart = useNestedRestartState({
selectedJobStep: () => selectedJobStep,
job: () => job,
graphModuleStates: () => graphModuleStates,
expandedSubflows: () => expandedSubflows
})
// When the selection can't be restarted from (nothing clicked, Input/Result,
// a step inside a parallel loop), the button targets the top-level step that
// failed the run, so it is visible without hunting for it in a large graph.
// A continue-on-error step keeps its `Failure` status on a run that succeeds,
// so only a failed run counts, and its last `Failure` is the one that ended it.
const failedTopLevelStep = $derived.by(() => {
if (job?.type !== 'CompletedJob' || job.success !== false || job.canceled) return undefined
const failures = job.flow_status?.modules?.filter((m) => m.type === 'Failure') ?? []
return failures[failures.length - 1]?.id
})
const failedRestart = useNestedRestartState({
selectedJobStep: () => failedTopLevelStep,
job: () => job,
graphModuleStates: () => graphModuleStates,
expandedSubflows: () => expandedSubflows
})
const selectionRestartable = $derived(
selectedJobStep !== undefined &&
(selectedRestart.topLevelRestartable || selectedRestart.nestedRestartSupported)
)
const restartStep = $derived(selectionRestartable ? selectedJobStep : failedTopLevelStep)
const restart = $derived(selectionRestartable ? selectedRestart : failedRestart)
function canRestart(state: ReturnType<typeof useNestedRestartState>) {
return (
job?.type === 'CompletedJob' &&
job.job_kind === 'flow' &&
(state.topLevelRestartable || state.nestedRestartSupported)
)
}
let testIsLoading = $state(false)
let jobLoader: JobLoader | undefined = $state(undefined)
@@ -643,6 +672,36 @@
/>
{/if}
{#snippet flowRestartButton(
state: ReturnType<typeof useNestedRestartState>,
step: string,
triggerStyle: 'button' | 'link'
)}
{#if job}
<FlowRestartButton
jobId={job.id}
selectedJobStep={step}
selectedJobStepType={state.selectedJobStepType}
restartBranchNames={state.restartBranchNames}
nestedPath={state.nestedRestartSupported ? state.nestedRestartPath : undefined}
nestedTopStepId={state.nestedRestartTopStepId}
nestedTopBranchOrIterationN={state.nestedRestartTopBranchOrIterationN}
presetIterationN={state.topLevelLoopIteration}
iterationCounts={state.iterationCounts}
nestedPathIterationCounts={state.nestedPathIterationCounts}
onRestartComplete={(newJobId) => {
goto('/run/' + newJobId + '?workspace=' + $workspaceStore)
}}
flowPath={job.script_path}
flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined}
disabled={!$enterpriseLicense}
enterpriseOnly={!$enterpriseLicense}
{triggerStyle}
unifiedSize="sm"
/>
{/if}
{/snippet}
<Portal name="persistent-run">
<PersistentScriptDrawer bind:this={persistentScriptDrawer} />
</Portal>
@@ -738,13 +797,13 @@
<Button
nonCaptureEvent
variant="default"
unifiedSize="md"
unifiedSize="sm"
startIcon={{ icon: Trash }}
/>
{/snippet}
</Dropdown>
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
<Button href={runsHref} variant="default" unifiedSize="md" startIcon={{ icon: List }}>
<Button href={runsHref} variant="default" unifiedSize="sm" startIcon={{ icon: List }}>
View runs
</Button>
{/if}
@@ -772,7 +831,7 @@
]}
>
{#snippet buttonReplacement()}
<Button nonCaptureEvent variant="default" unifiedSize="md" startIcon={{ icon: Share2 }}>
<Button nonCaptureEvent variant="default" unifiedSize="sm" startIcon={{ icon: Share2 }}>
Share
</Button>
{/snippet}
@@ -794,7 +853,7 @@
class="h-auto"
>
{#snippet buttonReplacement()}
<Button nonCaptureEvent unifiedSize="md" variant="subtle">
<Button nonCaptureEvent unifiedSize="sm" variant="subtle">
<div class="flex flex-row items-center">
<EllipsisVertical size={14} />
</div>
@@ -805,7 +864,7 @@
{/if}
{#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)}
<Button
unifiedSize="md"
unifiedSize="sm"
variant="default"
startIcon={{ icon: GitBranch }}
on:click={forkPreview}
@@ -817,7 +876,7 @@
{/if}
{#if persistentScriptDefinition !== undefined}
<Button
unifiedSize="md"
unifiedSize="sm"
variant="default"
startIcon={{ icon: Activity }}
on:click={() => {
@@ -830,7 +889,7 @@
{#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)}
{#if !forceCancel}
<Button
unifiedSize="md"
unifiedSize="sm"
variant="accent"
destructive
startIcon={{ icon: TimerOff }}
@@ -848,7 +907,7 @@
</Button>
{:else}
<Button
unifiedSize="md"
unifiedSize="sm"
variant="accent"
destructive
startIcon={{ icon: TimerOff }}
@@ -864,7 +923,7 @@
{/if}
{#if job?.schedule_path}
<Button
unifiedSize="md"
unifiedSize="sm"
variant="default"
on:click={() => {
if (!job || !job.schedule_path) {
@@ -875,26 +934,8 @@
startIcon={{ icon: Calendar }}>Edit schedule</Button
>
{/if}
{#if job?.type === 'CompletedJob' && job?.job_kind === 'flow' && selectedJobStep !== undefined && (restart.topLevelRestartable || restart.nestedRestartSupported) && job.id}
<FlowRestartButton
jobId={job.id}
{selectedJobStep}
selectedJobStepType={restart.selectedJobStepType}
restartBranchNames={restart.restartBranchNames}
nestedPath={restart.nestedRestartSupported ? restart.nestedRestartPath : undefined}
nestedTopStepId={restart.nestedRestartTopStepId}
nestedTopBranchOrIterationN={restart.nestedRestartTopBranchOrIterationN}
presetIterationN={restart.topLevelLoopIteration}
iterationCounts={restart.iterationCounts}
nestedPathIterationCounts={restart.nestedPathIterationCounts}
onRestartComplete={(newJobId) => {
goto('/run/' + newJobId + '?workspace=' + $workspaceStore)
}}
flowPath={job.script_path}
flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined}
disabled={!$enterpriseLicense}
enterpriseOnly={!$enterpriseLicense}
/>
{#if restartStep !== undefined && canRestart(restart)}
{@render flowRestartButton(restart, restartStep, 'button')}
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
<Button
@@ -909,7 +950,7 @@
`#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
)
}}
unifiedSize="md"
unifiedSize="sm"
variant="default"
startIcon={{ icon: RefreshCw }}
loading={runImmediatelyLoading}
@@ -936,10 +977,9 @@
on:click={() => {
$initialArgsStore = job?.args
}}
unifiedSize="md"
unifiedSize="sm"
variant="default"
disabled={!showEditButton}
size="sm"
startIcon={{ icon: Pen }}>Edit</Button
>
{#if showEditButton}
@@ -951,7 +991,7 @@
target: { kind: isScript ? 'script' : 'flow', path: job?.script_path ?? '' },
workspaceId: $workspaceStore ?? undefined
}}
btnProps={{ unifiedSize: 'md' }}
btnProps={{ unifiedSize: 'sm' }}
/>
{/if}
{/if}
@@ -962,9 +1002,8 @@
onEditInForkClick(e, isScript ? 'script' : 'flow', job?.script_path ?? '', {
hasHref: true
})}
unifiedSize="md"
unifiedSize="sm"
variant="default"
size="sm"
startIcon={{ icon: Pen }}>{editInForkLabel($workspaceStore, $userWorkspaces)}</Button
>
{/if}
@@ -973,7 +1012,7 @@
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
<Button
href={viewHref}
unifiedSize="md"
unifiedSize="sm"
variant="accent"
startIcon={{
icon:
@@ -1024,7 +1063,18 @@
textPosition="bottom"
slim
showStepId
/>
>
{#snippet errorAction()}
{#if failedTopLevelStep}
<span>at step {failedTopLevelStep}</span>
{#if $enterpriseLicense && canRestart(failedRestart)}
<span>·</span>
{@render flowRestartButton(failedRestart, failedTopLevelStep, 'link')}
<span>on this flow version, or on a new one after you fix it</span>
{/if}
{/if}
{/snippet}
</FlowProgressBar>
{#if suspendStatus}
<FlowExecutionStatus
{job}