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
9 changes: 4 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 30 additions & 13 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { FlowEditorModal } from './components/FlowEditorModal'
import { buildStarterYaml } from './lib/starter-yaml'
import { useFlowList, useFlowData } from './hooks/useFlowPolling'
import { useFlowMutations } from './hooks/useFlowMutations'
import type { FlowNode, FlowStep, Flow } from './types'
import type { FlowNode, FlowStep, FlowData, Flow } from './types'

interface FlowNavItem {
flow: { nodes: FlowNode[]; steps?: FlowStep[] }
Expand Down Expand Up @@ -205,14 +205,25 @@ function App() {
// Track current step index for resume
const currentStepRef = useRef(0)
const [inspectedStep, setInspectedStep] = useState<FlowStep | null>(null)
// Identifies the (from, to, data) the user clicked, so the inspect panel
// can highlight the matching section. Disambiguates broadcast steps where
// multiple targets share one data object reference.
const [inspectedFocus, setInspectedFocus] = useState<{
from?: string
to?: string
data?: FlowData
} | null>(null)
const displayFlowRef = useRef(displayFlow)
useEffect(() => {
displayFlowRef.current = displayFlow
}, [displayFlow])
const handleStepChange = useCallback((stepIndex: number) => {
currentStepRef.current = stepIndex
const steps = displayFlowRef.current?.flow.steps ?? []
if (steps[stepIndex]) setInspectedStep(steps[stepIndex])
if (steps[stepIndex]) {
setInspectedStep(steps[stepIndex])
setInspectedFocus(null)
}
}, [])

// Inspector panel state
Expand All @@ -223,11 +234,22 @@ function App() {
// Reset inspected step when flow changes
useEffect(() => {
setInspectedStep(null)
setInspectedFocus(null)
}, [selectedFlowId, flowStack.length])

const handleInspectStep = useCallback((step: FlowStep) => {
setInspectedStep(step)
}, [])
const handleInspectStep = useCallback(
(step: FlowStep, focus?: { from?: string; to?: string; data?: FlowData }) => {
setInspectedStep(step)
setInspectedFocus(focus ?? null)
setInspectorOpen(true)
// When focus is provided, the call originated from a carrot click —
// pause autoplay so the highlighted block doesn't get cleared by the
// next step's onInspectStep call (handleStepChange sets focus=null
// every advance, which would make the click feel like it did nothing).
if (focus) setPlaying(false)
},
[]
)

// Fallback: first step when nothing has been inspected
const currentStep: FlowStep | null = useMemo(() => {
Expand Down Expand Up @@ -349,14 +371,6 @@ function App() {
</div>
{selectedFlowId && (
<div className="flex items-center">
<button
aria-label={playing ? 'Pause flow' : 'Play flow'}
onClick={() => setPlaying((p) => !p)}
className="openhop-header-btn font-pixel text-xs px-3 py-1 border transition-colors"
style={{ fontSize: 10 }}
>
{playing ? '\u23F8 Pause' : '\u25B6 Play'}
</button>
<InspectorToggle open={inspectorOpen} onToggle={() => setInspectorOpen((o) => !o)} />
</div>
)}
Expand Down Expand Up @@ -466,6 +480,8 @@ function App() {
<FlowCanvas
flow={displayFlow}
playing={playing}
onTogglePlay={() => setPlaying((p) => !p)}
onPause={() => setPlaying(false)}
onDrillDown={handleDrillDown}
onDrilldownStep={handleAutoDrilldown}
onCycleComplete={handleCycleComplete}
Expand All @@ -480,6 +496,7 @@ function App() {
{inspectorOpen && selectedFlowId && (
<DataInspectionPanel
step={currentStep}
focus={inspectedFocus}
side={inspectorSide}
size={inspectorSize}
onSideChange={setInspectorSide}
Expand Down
43 changes: 31 additions & 12 deletions packages/web/src/AppFragment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { FlowEditorModal } from './components/FlowEditorModal'
import { buildStarterYaml } from './lib/starter-yaml'
import { buildShareUrl, decodeFragment } from './lib/share-url'
import type { FlowNode, FlowStep, Flow } from './types'
import type { FlowNode, FlowStep, FlowData, Flow } from './types'

interface FlowNavItem {
flow: { nodes: FlowNode[]; steps?: FlowStep[] }
Expand Down Expand Up @@ -149,22 +149,46 @@ export default function AppFragment() {

const currentStepRef = useRef(0)
const [inspectedStep, setInspectedStep] = useState<FlowStep | null>(null)
// (from, to, data) the user clicked, so the inspect panel can highlight
// the matching section. Disambiguates broadcast steps where multiple
// targets share one data object reference.
const [inspectedFocus, setInspectedFocus] = useState<{
from?: string
to?: string
data?: FlowData
} | null>(null)
const displayFlowRef = useRef(displayFlow)
useEffect(() => {
displayFlowRef.current = displayFlow
}, [displayFlow])
const handleStepChange = useCallback((stepIndex: number) => {
currentStepRef.current = stepIndex
const steps = displayFlowRef.current?.flow.steps ?? []
if (steps[stepIndex]) setInspectedStep(steps[stepIndex])
if (steps[stepIndex]) {
setInspectedStep(steps[stepIndex])
setInspectedFocus(null)
}
}, [])

const [inspectorOpen, setInspectorOpen] = useState(true)
const [inspectorSide, setInspectorSide] = useState<DockSide>('right')
const [inspectorSize, setInspectorSize] = useState(320)
useEffect(() => setInspectedStep(null), [hash, flowStack.length])
useEffect(() => {
setInspectedStep(null)
setInspectedFocus(null)
}, [hash, flowStack.length])

const handleInspectStep = useCallback((step: FlowStep) => setInspectedStep(step), [])
const handleInspectStep = useCallback(
(step: FlowStep, focus?: { from?: string; to?: string; data?: FlowData }) => {
setInspectedStep(step)
setInspectedFocus(focus ?? null)
setInspectorOpen(true)
// Pause autoplay on carrot click so the highlight isn't immediately
// wiped by the next step's onInspectStep (see App.tsx for details).
if (focus) setPlaying(false)
},
[]
)
const currentStep: FlowStep | null = useMemo(() => {
if (inspectedStep) return inspectedStep
const steps = currentFlowBody?.flow.steps ?? []
Expand Down Expand Up @@ -267,14 +291,6 @@ export default function AppFragment() {
>
✎ Edit
</button>
<button
aria-label={playing ? 'Pause flow' : 'Play flow'}
onClick={() => setPlaying((p) => !p)}
className="openhop-header-btn font-pixel text-xs px-3 py-1 border transition-colors"
style={{ fontSize: 10 }}
>
{playing ? '⏸ Pause' : '▶ Play'}
</button>
<InspectorToggle open={inspectorOpen} onToggle={() => setInspectorOpen((o) => !o)} />
</>
)}
Expand Down Expand Up @@ -362,6 +378,8 @@ export default function AppFragment() {
<FlowCanvas
flow={displayFlow}
playing={playing}
onTogglePlay={() => setPlaying((p) => !p)}
onPause={() => setPlaying(false)}
onDrillDown={handleDrillDown}
onDrilldownStep={handleAutoDrilldown}
onCycleComplete={handleCycleComplete}
Expand All @@ -375,6 +393,7 @@ export default function AppFragment() {
{inspectorOpen && decodedFlow && (
<DataInspectionPanel
step={currentStep}
focus={inspectedFocus}
side={inspectorSide}
size={inspectorSize}
onSideChange={setInspectorSide}
Expand Down
108 changes: 86 additions & 22 deletions packages/web/src/components/DataInspectionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import type { FlowStep, FlowData } from '../types'

export type DockSide = 'right' | 'bottom'

interface DataInspectionPanelProps {
step: FlowStep | null
/** Identifies the (from, to, data) the user clicked on the canvas, so
* the matching DataBlock highlights and scrolls into view. The
* triplet disambiguates:
* - broadcast steps (one source, many targets, shared data ref —
* `to` distinguishes which target)
* - parallel steps (each sub-step has its own from/to)
* - multi-data steps (`data` distinguishes which entry was clicked) */
focus?: { from?: string; to?: string; data?: FlowData } | null
side: DockSide
size: number
onSideChange: (side: DockSide) => void
Expand All @@ -17,6 +25,7 @@ const MAX_SIZE = 800

export function DataInspectionPanel({
step,
focus = null,
side,
size,
onSideChange,
Expand Down Expand Up @@ -88,7 +97,7 @@ export function DataInspectionPanel({
/>
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Header side={side} onSideChange={onSideChange} onClose={onClose} />
<StepBody step={step} />
<StepBody step={step} focus={focus} />
</div>
</aside>
)
Expand Down Expand Up @@ -215,7 +224,13 @@ function normalizeData(raw: FlowStep['data']): FlowData[] {
return [raw]
}

function StepBody({ step }: { step: FlowStep | null }) {
function StepBody({
step,
focus,
}: {
step: FlowStep | null
focus: { from?: string; to?: string; data?: FlowData } | null
}) {
if (!step) {
return (
<div
Expand Down Expand Up @@ -245,35 +260,84 @@ function StepBody({ step }: { step: FlowStep | null }) {
color: '#e0e0e0',
}}
>
{flows.map((f, i) => (
<section
key={`${f.from}-${f.to}-${i}`}
style={{
marginTop: i > 0 ? 12 : 0,
paddingTop: i > 0 ? 10 : 0,
borderTop: i > 0 ? '1px solid #2a2a4a' : undefined,
}}
>
<div style={{ color: '#4a9eff', marginBottom: 6 }}>
{f.from} &rarr; {f.to}
</div>
{f.data.length === 0 && <div style={{ color: '#888' }}>No data.</div>}
{f.data.map((d, di) => (
<DataBlock key={di} data={d} separated={di > 0} />
))}
</section>
))}
{flows.map((f, i) => {
// A section matches focus when its from/to align (when supplied).
// Then within the section, only the specific data block matches.
// Both checks needed to disambiguate broadcast (shared data ref
// across targets) and parallel (per-sub-step from/to).
const sectionMatchesFocus =
!!focus &&
(focus.from === undefined || focus.from === f.from) &&
(focus.to === undefined || focus.to === f.to)
return (
<section
key={`${f.from}-${f.to}-${i}`}
style={{
marginTop: i > 0 ? 12 : 0,
paddingTop: i > 0 ? 10 : 0,
borderTop: i > 0 ? '1px solid #2a2a4a' : undefined,
}}
>
<div style={{ color: '#4a9eff', marginBottom: 6 }}>
{f.from} &rarr; {f.to}
</div>
{f.data.length === 0 && <div style={{ color: '#888' }}>No data.</div>}
{f.data.map((d, di) => (
<DataBlock
key={di}
data={d}
separated={di > 0}
highlighted={sectionMatchesFocus && (focus?.data === undefined || d === focus.data)}
/>
))}
</section>
)
})}
</div>
)
}

function DataBlock({ data, separated }: { data: FlowData; separated: boolean }) {
function DataBlock({
data,
separated,
highlighted,
}: {
data: FlowData
separated: boolean
highlighted: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
// Scroll the highlighted block into view when it changes. `block: 'nearest'`
// keeps the panel from over-scrolling if the block is already visible.
useEffect(() => {
if (highlighted && ref.current) {
ref.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}, [highlighted])

return (
<div
ref={ref}
style={{
marginTop: separated ? 10 : 0,
paddingTop: separated ? 8 : 0,
borderTop: separated ? '1px dashed #2a2a4a' : undefined,
// Highlighted block: thick left bar in brand cyan + tinted bg +
// soft glow. Cranked up from a subtle accent because users said
// they couldn't tell what changed when they clicked a carrot.
...(highlighted
? {
borderLeft: '4px solid #4a9eff',
background: 'rgba(74,158,255,0.18)',
boxShadow: '0 0 12px rgba(74,158,255,0.35)',
paddingLeft: 8,
paddingTop: 6,
paddingBottom: 6,
marginLeft: -12,
marginRight: -8,
borderRadius: '0 4px 4px 0',
}
: null),
}}
>
{data.label && <div style={{ marginBottom: data.fields?.length ? 6 : 0 }}>{data.label}</div>}
Expand Down
Loading
Loading