feat: show flow step detail inside the graph tab on narrow detail layouts (#11168)

* feat: show flow step detail as a page inside the graph tab on narrow detail layouts

* fix: pad the detail step tab, wrapped header row and raise the tabbed layout breakpoint

* fix: keep the step header pinned and switch to triggers on every trigger node tap
This commit is contained in:
Guilhem
2026-09-16 16:12:21 +02:00
committed by GitHub
parent 3d08197182
commit 64dffe6106
5 changed files with 171 additions and 64 deletions
@@ -19,6 +19,7 @@
import { Copy, Expand } from 'lucide-svelte'
import HighlightTheme from './HighlightTheme.svelte'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
import FlowGraphViewerStepHeader from './FlowGraphViewerStepHeader.svelte'
interface Props {
schema?: any | undefined
@@ -28,6 +29,8 @@
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
// editors); used to qualify resource links.
workspace?: string
/** Given, the step header starts with a back control that calls it. */
onBack?: () => void
}
let {
@@ -35,7 +38,8 @@
stepDetail = undefined,
jobScriptHash = undefined,
hideDefaultInputs = false,
workspace = undefined
workspace = undefined,
onBack = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let codeViewer: Drawer | undefined = $state()
@@ -104,57 +108,20 @@
{/if}
</div>
{:else if stepDetail == 'Input'}
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
{#if schema}
<SchemaViewer {schema} />
{:else}
<p class="font-medium text-secondary text-center pt-4 pb-8"> No input schema </p>
{/if}
{:else if stepDetail == 'Result'}
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
{:else if typeof stepDetail != 'string' && stepDetail.value}
<!-- A direct child of the scrolling root: a sticky row can only hold within its parent's
box, so wrapped with the path link below it would scroll away with that wrapper. -->
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
<div class="">
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
<Badge color="indigo">
{stepDetail.id}
</Badge>
{/if}
<span
class={twMerge(
'font-semibold text-emphasis text-sm',
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
)}
>
{#if stepDetail.summary}
{stepDetail.summary}
{:else if stepDetail.value.type == 'identity'}
Identity
{:else if stepDetail.value.type == 'forloopflow'}
For loop {#if stepDetail.value.parallel}(parallel){/if}
{#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.value.type == 'branchall'}
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
{:else if stepDetail.value.type == 'branchone'}
Run one branch
{:else if stepDetail.value.type == 'flow'}
Inner flow
{:else if stepDetail.value.type == 'whileloopflow'}
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.id === 'failure'}
Error handler
{:else if stepDetail.id === 'preprocessor'}
Preprocessor
{:else if stepDetail.value.type == 'rawscript'}
Inline {stepDetail.value.language} script
{:else if stepDetail.value.type == 'script'}
Workspace script
{:else if stepDetail.value.type == 'aiagent'}
AI Agent
{/if}
</span>
</div>
{#if stepDetail.value.type == 'script'}
<div class="pb-2">
<a
@@ -0,0 +1,80 @@
<script lang="ts">
import type { FlowModule } from '$lib/gen'
import { Badge, Button } from './common'
import { ArrowLeft } from 'lucide-svelte'
interface Props {
/** A module, or the graph's pseudo-nodes by id (`Input`, `Result`). */
stepDetail: FlowModule | string
/** Given, the row starts with a back control; the caller decides where back leads. */
onBack?: () => void
}
let { stepDetail, onBack = undefined }: Props = $props()
const module = $derived(typeof stepDetail === 'string' ? undefined : stepDetail)
// The error handler and the preprocessor are named by their role, not by an id badge.
const showId = $derived(
module?.id !== undefined && module.id !== 'failure' && module.id !== 'preprocessor'
)
const title = $derived.by((): string => {
if (typeof stepDetail === 'string') {
if (stepDetail === 'Input') return 'Flow inputs'
if (stepDetail === 'Result') return 'Result'
return stepDetail
}
if (stepDetail.summary) return stepDetail.summary
if (stepDetail.id === 'failure') return 'Error handler'
if (stepDetail.id === 'preprocessor') return 'Preprocessor'
const v = stepDetail.value
switch (v?.type) {
case 'identity':
return 'Identity'
case 'forloopflow':
return (
'For loop' +
(v.parallel ? ' (parallel)' : '') +
(v.skip_failures ? ' (skip failures)' : '') +
(v.squash ? ' (squash)' : '')
)
case 'whileloopflow':
return (
'While loop' + (v.skip_failures ? ' (skip failures)' : '') + (v.squash ? ' (squash)' : '')
)
case 'branchall':
return 'Run all branches' + (v.parallel ? ' (parallel)' : '')
case 'branchone':
return 'Run one branch'
case 'flow':
return 'Inner flow'
case 'rawscript':
return `Inline ${v.language} script`
case 'script':
return 'Workspace script'
case 'aiagent':
return 'AI Agent'
default:
return stepDetail.id
}
})
</script>
<!-- -top-2: the row pins at the scroll container's content edge, and FlowGraphViewerStep pads
its root by that much, so at top-0 the body would show through the padding above the row. -->
<div class="sticky -top-2 z-10 flex w-full items-center gap-2 bg-surface py-2">
{#if onBack}
<Button
unifiedSize="sm"
variant="subtle"
iconOnly
startIcon={{ icon: ArrowLeft }}
title="Back to the flow graph"
onclick={onBack}
/>
{/if}
{#if showId && module}
<Badge color="indigo">{module.id}</Badge>
{/if}
<span class="min-w-0 truncate text-sm font-semibold text-emphasis" {title}>{title}</span>
</div>
@@ -137,7 +137,7 @@
<div class="border-b">
<div class="mx-auto">
<div
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12"
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0"
>
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
<div class={twMerge('min-w-0', $userStore?.operator ? 'pl-10' : '')}>
@@ -1,5 +1,6 @@
<script lang="ts">
import { Tabs, Tab, TabContent } from '$lib/components/common'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import DetailPageDetailPanel from './DetailPageDetailPanel.svelte'
import FlowViewerInner from '../FlowViewerInner.svelte'
@@ -11,10 +12,14 @@
forceSmallScreen?: boolean
isChatMode?: boolean
header?: import('svelte').Snippet
form?: import('svelte').Snippet
/** `graphInline`: whether the form should carry the flow graph under it. It does in the
* split layout; the tabbed layout gives the graph a tab of its own. */
form?: import('svelte').Snippet<[{ graphInline: boolean }]>
scriptRender?: import('svelte').Snippet
save_inputs?: import('svelte').Snippet
flow_step?: import('svelte').Snippet
/** `onBack`: set where the step is a page pushed over the graph, so its header can lead
* back; absent where the step has a tab of its own. */
flow_step?: import('svelte').Snippet<[{ onBack?: () => void }]>
triggers?: import('svelte').Snippet
flow_graph?: import('svelte').Snippet
}
@@ -34,7 +39,7 @@
flow_graph
}: Props = $props()
let mobileTab: 'form' | 'detail' = $state('form')
let mobileTab = $state('form')
let clientWidth = $state(window.innerWidth)
@@ -44,7 +49,22 @@
const triggers_render = $derived(triggers)
const flow_graph_render = $derived(flow_graph)
const useDesktopLayout = $derived(clientWidth >= 768 && !forceSmallScreen)
// 1024 (Tailwind `lg`), where the page header also stops collapsing its actions: below it
// the split's right pane is under 340px, too narrow for a step's code.
const useDesktopLayout = $derived(clientWidth >= 1024 && !forceSmallScreen)
// The tabbed layout has no Step tab: a step opens as a page pushed over the graph tab, and
// the way back is the graph.
const graphPage = $derived(selected === 'flow_step' ? 'step' : 'graph')
/** Show the triggers pane: the right pane's tab in the split layout, the Triggers tab in the
* tabbed one. A method rather than a value the caller sets, because asking twice in a row is
* two requests — the tab may have been left in between — and a value set to what it already
* holds changes nothing. */
export function showTriggers() {
selected = 'triggers'
mobileTab = 'triggers'
}
</script>
<main class="h-screen w-full" bind:clientWidth>
@@ -54,7 +74,7 @@
<div class="grow min-h-0 w-full">
<Splitpanes>
<Pane size={65} minSize={50}>
{@render form?.()}
{@render form?.({ graphInline: true })}
</Pane>
<Pane size={35} minSize={15}>
<DetailPageDetailPanel bind:selected {isOperator} {flow_json}>
@@ -65,7 +85,11 @@
{@render save_inputs_render?.()}
{/snippet}
{#snippet flow_step()}
{@render flow_step_render?.()}
<!-- No overflow of its own: the step body is the scroll container its sticky
header keys on, so it has to be the flex item that shrinks. -->
<div class="flex min-h-0 grow flex-col p-2">
{@render flow_step_render?.({})}
</div>
{/snippet}
{#snippet triggers()}
{@render triggers_render?.()}
@@ -79,12 +103,15 @@
<div class="h-full w-full flex flex-col">
{@render header?.()}
<div class="grow min-h-0 w-full flex flex-col">
<Tabs bind:selected={mobileTab} wrapperClass="flex-none">
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
classic scrollbars would spend a track under them, opening a band between the tabs
and the content. Wheel, trackpad and drag still scroll the strip. -->
<Tabs bind:selected={mobileTab} wrapperClass="flex-none no-scrollbar">
<Tab value="form" label={isChatMode ? 'Chat' : 'Run form'} />
{#if !isChatMode}
<Tab value="saved_inputs" label="Inputs" />
{/if}
{#if isChatMode && flow_json}
{#if flow_json}
<Tab value="flow" label="Flow graph" />
{/if}
{#if !isOperator}
@@ -99,7 +126,7 @@
{#snippet content()}
<div class="grow min-h-0 overflow-y-auto">
<TabContent value="form" class="flex flex-col flex-1 h-full">
{@render form?.()}
{@render form?.({ graphInline: false })}
</TabContent>
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
@@ -108,9 +135,9 @@
<TabContent value="triggers" class="flex flex-col flex-1 h-full mt-[-2px]">
{@render triggers?.()}
</TabContent>
{#if isChatMode && flow_json}
{#if flow_json}
<TabContent value="flow" class="flex flex-col flex-1 h-full">
{@render flow_graph_render?.()}
{@render pagedGraph()}
</TabContent>
{/if}
{#if flow_json}
@@ -128,3 +155,34 @@
</div>
{/if}
</main>
<!-- Warmed so a tab reopened on the step page has the graph built before the way back is taken;
the pages are absolutely positioned, so each carries its own scroll. -->
{#snippet pagedGraph()}
<PagedContent
warm
class="h-full"
current={graphPage}
onNavigate={(key) => {
if (key === 'graph') selected = 'saved_inputs'
}}
pages={[
{ key: 'graph', content: graphPageContent },
{ key: 'step', content: stepPageContent }
]}
/>
{/snippet}
{#snippet graphPageContent()}
<div class="h-full overflow-y-auto flex flex-col">
{@render flow_graph_render?.()}
</div>
{/snippet}
{#snippet stepPageContent()}
<!-- The step body brings its own inner padding; this outer band brings it level with the
Inputs and Export tabs. No overflow of its own, as in the split layout above. -->
<div class="flex min-h-0 grow flex-col p-2">
{@render flow_step_render?.({ onBack: () => (selected = 'saved_inputs') })}
</div>
{/snippet}
@@ -491,6 +491,7 @@
let stepDetail: FlowModule | string | undefined = $state(undefined)
let rightPaneSelected = $state('saved_inputs')
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
let detailLayout: DetailPageLayout | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
let path = $derived(page.params.path ?? '')
@@ -546,6 +547,7 @@
{/if}
<DetailPageLayout
bind:this={detailLayout}
bind:selected={rightPaneSelected}
isOperator={$userStore?.operator}
forceSmallScreen={chatInputEnabled}
@@ -560,7 +562,7 @@
{#snippet header()}
<DetailPageHeader
on:seeTriggers={() => {
rightPaneSelected = 'triggers'
detailLayout?.showTriggers()
}}
{mainButtons}
menuItems={getMenuItems(flow, deployUiSettings)}
@@ -596,7 +598,7 @@
isFlow
selected={rightPaneSelected == 'triggers'}
onSelect={async (triggerIndex: number) => {
rightPaneSelected = 'triggers'
detailLayout?.showTriggers()
await tick()
triggersState.selectedTriggerIndex = triggerIndex
}}
@@ -627,7 +629,7 @@
{/if}
</DetailPageHeader>
{/snippet}
{#snippet form()}
{#snippet form({ graphInline }: { graphInline: boolean })}
<div class="px-3">
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
</div>
@@ -771,7 +773,7 @@
{/if}
</div>
</div>
{#if !chatInputEnabled}
{#if graphInline}
<div class="grow min-h-0">
<FlowGraphViewer
triggerNode={true}
@@ -789,7 +791,7 @@
}
}}
on:triggerDetail={(e) => {
rightPaneSelected = 'triggers'
detailLayout?.showTriggers()
}}
noBorder={true}
/>
@@ -817,10 +819,10 @@
/>
{/snippet}
{#snippet flow_step()}
{#snippet flow_step({ onBack }: { onBack?: () => void })}
{#if flow}
{#if stepDetail}
<FlowGraphViewerStep schema={flow.schema} {stepDetail} />
<FlowGraphViewerStep schema={flow.schema} {stepDetail} {onBack} />
{/if}
{/if}
{/snippet}
@@ -849,7 +851,7 @@
triggerNode={true}
download
{flow}
noSide={false}
noSide={true}
noBorder
minHeight={flowGraphHeight}
on:select={(e) => {
@@ -862,7 +864,7 @@
}
}}
on:triggerDetail={(e) => {
rightPaneSelected = 'triggers'
detailLayout?.showTriggers()
}}
/>
</div>