Run and Inbox modes for Dex Web v2 - #511
Open
longquanzheng wants to merge 21 commits into
Open
longquanzheng wants to merge 21 commits into
longquanzheng wants to merge 21 commits into
Conversation
One /v2 screen served two jobs at once: driving a run on the definition canvas, and clearing work that needs a person. Separate them. /v2/run keeps today's behaviour — listing, Display, Actions and the canvas — and gains a link to the v1 run page, which owns Timeline, the event browser, Stop and Time Travel. /v2/queue draws no canvas. Clearing a queue does not need the shape of the process, so "See the process" opens the same run in Run mode instead. The listing, the filter builder and the Display/Actions panel are shared rather than duplicated, so the two modes cannot drift apart. No contract, API or analyzer change.
The queue blanked its rows whenever a refresh failed, so "nothing is open" and "we cannot see the process" rendered identically while calling for opposite actions. Fold reads through a held value instead: a failed refresh keeps the rows that were true a moment ago and says they are stale. A run whose owning worker has exited is a third answer again — permanently unreadable while the server stays healthy. Measured rather than assumed: that surfaces as FailedPrecondition, the same code the server uses for "Flow is not active", and a closed run produces a byte-identical error. Neither the code nor the message separates them, so stranded is (running run, dial failure) and is only knowable once somebody opens the run. The row is then marked, not dropped, because it is still unresolved. readResponseJSON parsed grpcCode and discarded it; it now reaches the caller on a DexAPIError without altering any existing message. Queue mode narrows to open runs through a real server-side filter rather than dropping rows after the server paginated, and states its own scope: which status it filtered on, that counts describe one page, and that Action availability is decided per run when opened. Deliberately not here: no attention kinds, no tally, and no claim that a person is needed. A dex:when condition says when an Action may be offered, not who must act, and the search index it would be evaluated against is eventually consistent. That needs a declared role, which the contract does not yet carry.
Four defects, all found by auditing the branch rather than by a test. A dead worker was only explained in Queue mode. Run rendered SelectedRunPanel without the run's status, so isStrandedRunFailure could never fire there and an operator saw a raw gRPC dial error instead of the sentence that says the process itself is fine. Both modes now pass the status and remember what they learn, so the row is marked in either one. The Display panel ignored the liveness fold the list beside it was already using, so half the view could tell a stale answer from a fresh one and half could not. It now holds its value the same way, and a write that fails is kept separate from a read that went stale — a rejected Action does not make the fields old. The queue's scope sentence was hardcoded while the filter row it described was user-deletable, so deleting the filter left the view claiming a scope it no longer had. It is now derived from the filters that are actually applied, and says plainly when nothing is narrowing the list. A failed search printed its reason twice. Also: nineteen classes ported from the prototype matched no markup. The ones whose markup this change restores are now used; the rest are deleted and will return with the commits that introduce theirs. sc-actions was the inverse — a class in use that nothing styled.
/v2/run/:flowType/:flowId/debug/:runId renders the v1 run-details components unchanged inside the v2 shell. Layout and information density are preserved deliberately: this is the view an engineer drills into, and its four tabs are already the comprehensive record. Keyed per run, because Time Travel and continue-as-new navigate the run chain while the Run view only ever shows the current run. With no runId in the URL it resolves the current one. /v1 keeps serving the same components, so globals.css cannot be rewritten in place. debug.css is therefore a colour-and-type layer scoped under .v2-debug, the same shape as the .v2-shell .ppan block already in canvas.css. globals.css still owns the layout. Mapped by meaning, not by literal. v1 spells its palette as 74 distinct hex values and uses green for three different things; v2 reserves green for done, so interactive affordances move to blue. v2 has no interactive token at all — every --p-* is a status — so the accent is aliased once and named. RunDetailsPage now takes its breadcrumb and its run-chain path from whichever shell hosts it, passed explicitly at both call sites rather than defaulted. Verified by measurement rather than by eye: a probe walks every element in all four tabs in dark mode looking for a light background, and reports zero. The first version of that probe was wrong — it split rgba alpha on \d+ and read 0.97 as 0 — which is how a white panel header survived two passes. Known remaining: the Deep Dive's own deep links (SubFlow drill-downs, previous runs) still point at /v1/flows. They are reachable and correct there; routing them back into v2 needs a context rather than nine more props.
Three panes with the weights the job wants: a narrow run switcher, the canvas wide, and a drawer for details and actions. Before this, one left aside stacked a flow-type picker, a filter builder, a result list, the Display fields and the Actions, and the canvas — the thing the view is about — got what was left. Choosing a run now lands on where it stopped. The canvas reveals and selects the Step the run is waiting on, and the drawer's top band names it with the templated why-line the canvas already had. Clicking another Step moves the band and nothing else: the run's facts and its Actions do not move, because Actions are why an Admin opened the run. They are sticky to the drawer's bottom edge. The Display pane was capped at 42% of the aside it shared, which is why its Reject button fell off the bottom of the window. Its own column has no cap. Promoted: the run's identity — run ID, started, elapsed — which v2 never rendered even though the canvas already fetched it; and Stop, which v2 could not do at all. Scoping a search left for the Queue. One clock. The canvas owned a 5s run poll while the Display pane was a one-shot read, so the diagram moved and the fields silently did not. The canvas now reports each poll and the drawer refreshes on the same beat. Caught while verifying: suppressing nothing left FOUR columns, because the canvas still rendered its own Step panel beside the new drawer — the canvas was squeezed worse than before. The canvas now yields the detail surface to its host. Deferred, deliberately: the Step's WaitFor conditions and Execute branches are definition structure and stay in the Deep Dive. The band carries the Step's dex:explanation, which is the semantic half.
Four things the Run view got wrong. The runs were numbered 1 to 4. Nothing chose those numbers: the list was an <ol> and only the queue's own class reset list-style, so they were browser ordinal markers standing in for an order they did not describe. The real order was already there — the search handler sorts by start time descending — so the fix is to state it rather than decorate it, and to lift open runs above closed ones. Which run most needs somebody still cannot be ranked: that wants a declared role. Each run was drawn as a bordered, filled card, so four runs read as four objects competing with the canvas. Now hairlines between rows, a heavier rule between the open and closed groups, and selection as a left accent plus a faint fill. A run is an entry in a list. Actions were below the reported fields and pinned to the drawer's bottom edge. They now sit directly under the band that says what the run is waiting at, which is where somebody who already knows the case looks. The pinning goes with them, because nothing is below the fold any more. The Queue deliberately keeps the other order: a participant needs the evidence before the decision, which is the argument the prototype's panel was built around. One prop, stated at both call sites. "Deep dive" said nothing about what was behind it. "Inspect" borrows the devtools convention, so it signals advanced without a label claiming to, and a bordered mono chip reads as an engineering surface in a design system that already spells technical values in mono. Also: the drawer named the run twice, once in its header and once in the panel below it.
Type. The v2 ramp ran 9 to 11.5px, which is below comfortable reading size for prose values like a recommendation rationale. Everything moves up one tier. Fit. Fit was capped at 1:1 on the reasoning that a three-Step flow blown up to fill a wall reads as a mistake, but the cap also left a wide canvas mostly empty whenever the graph already fitted. Raised, and the padding trimmed — the top reserved 22% for a control bar that needs about 6%. Measured: the graph now uses 89% of the width and 80% of the height, up from 82% and 73%. Arrowheads. They are markerUnits="userSpaceOnUse", so unlike the stroke they do not take the zoom compensation — a 7-unit head renders as 3.5 screen pixels at half zoom, which is why direction was unreadable without zooming in. Enlarged. A dimmed edge was at 0.24 opacity, so selecting a Step made the rest of the skeleton vanish rather than recede; it now stays a visible line. Choosing a run with an open Action zooms in on the Step it stopped at, rather than panning to it at whatever zoom was current, and closing the panel gives the whole graph back. The zoom is a prop rather than an imperative call, because the drawer opening resizes the pane and the resulting refit was racing the focus and winning. The drawer has a close button, and taking an Action closes it: the thing it was opened for has been resolved, and the run's status updates in both panels. Timezone follows absolute timestamps into the Deep Dive, which is the only view that turns on them. "Work queue" is now "Inbox" — the most familiar name for a place things arrive for you, and role-specific in a way a word like "queue" is not. A stripe under the canvas was the run-selection prompt: a third grid child with nowhere to sit, so it wrapped onto its own row. It is hover help on the Runs heading now, the canvas reaches the bottom, and the order sentence goes with it. Flow type is a select rather than a stack of buttons.
The Run drawer showed a run and its Actions but never said what the Step it was waiting at is FOR. That is a Flow-level fact, not a run one, so it appends below the reported fields rather than displacing them. Purpose first, then placement: the declared explanation, the phase the Step sits in, who moves it along, what reaches it, what it waits for, where it goes next, and where it goes if it fails. Every line is read from the graph. A Flow that did not declare a purpose gets a sentence saying so rather than an invented one. Recovery is kept out of "then goes to". A failure edge is a different fact from the ordinary path, and merging them would claim the Step routes somewhere it only reaches when something breaks. The wait line reuses the wait phase's own prose instead of rebuilding it from condition labels — that string already paraphrases the framework vocabulary while keeping identifiers verbatim. THE COMMITTED FLOW DEFINITIONS WERE STALE, and that is why this looked impossible. Both refund sources declare exactly one dex:explanation per Step, 30 in total, as the repository requires — but the JSON under docs/src/data/flow-definitions carried no metadata at all, so every Step read "No dex:explanation on this Step" in the Deep Dive too. Regenerated from the same sources: 7 of 7 and 23 of 23. Note that Dex snapshots the rendering directory at startup, so a regeneration needs a restart to be visible. Also: the explanation was briefly in the band as well, and a grown case panel left a gap above the new block.
The Step block was flush against the drawer's left edge and ran to its bottom. Its neighbour pads itself; it did not. And a Step's meaning does not depend on a run, so clicking a Step with nothing selected now opens the drawer on the Step alone: no run header, no waiting band, no fields and no Actions, because none of those exist yet. Closing it clears the canvas selection rather than leaving a Step highlighted with nothing explaining it. That also makes the drawer's two jobs explicit. With a run it answers "what is this run waiting on and what can I do"; without one it answers "what is this Step for", which is a question about the Flow.
Inbox was still the old shell while Run had been rebuilt around it, so the two read as different products: a three-line page title Run does not have, a 26rem pane carrying a filter builder and a pager, rows of unlabelled values, a wrapping "Ask again", full timestamps against Run's time-only, and a case panel with no chrome spread across 1250px so a label and its value sat a screen apart. Unified by sharing the component rather than copying its styles. RunList is the one list both views render, with the heading, the note beside it and an optional scope control as props — the rows are identical by construction and cannot drift. FlowListing and RunSwitcher are gone; they were two implementations of it. The filter builder moves into its own component behind a disclosure. Collapsed, so the list reads as a list; never hidden, because the view states what it narrowed to and a reader who disagrees has to reach the thing that narrowed it. The sentence is the summary line, so it is visible either way. The case panel is capped and centred: a reading column with a void beside it reads as broken, and a label-value pair should not span a screen. It gains the close button Run's drawer already had. The page title goes. The nav says Inbox, and the note explaining the absent diagram was answering a question that only existed before Run was its own view. What still differs is deliberate: no canvas, and evidence before the decision.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
longquanzheng
marked this pull request as ready for review
September 20, 2026 18:05
A refund run now stops for a person twice: once to approve the refund, then again to confirm or rewrite the message that goes to the customer. Only one gate is ever open, and the second one holds the email back until somebody has read it — before this, the message was composed and sent in the same breath. Three Steps rather than one, copying the shape the approval gate already uses: DraftCustomerMessageStep composes the message and mints the gate, ConfirmCustomerMessageStep only waits, and SendCustomerMessageStep sends whatever was confirmed. The predecessor mints the key so nothing writes inside a wait_for phase, which is the mistake that registers an empty wait condition and parks a Step forever. The gates share one counter and one gate-request-key, so the keys read :gate:1 and :gate:2, and each waits on its own Channel so neither can consume the other's answer. Two Actions, not three: refusing to tell the customer anything is not a decision worth offering. Searching. Dex can only search Indexed Attributes, and this Flow declared one — case-status, keyword, exact match only. It now declares the two a person actually remembers a case by: customer-email as fulltext, so either the whole address or just the domain finds it, and refund-amount as a double in dollars, so a range reads like the figure people say out loud. RefundCase gained the email address, which it never carried. RECORDED IN examples/go/README.md: changing a Flow type's Indexed Attributes while a store still holds runs of that type can stop the Worker before it binds, and it does so silently — the process lives, logs nothing after Gin's banner, and never opens its port, so requests fail to connect instead of returning an error. A fresh store proves it, which is how this was found. The README already said the sync happens before the listener opens; it did not say what happens when the sync cannot finish. The analyzer caught a real error on the way: the new Steps were inserted between SendCustomerMessageStep's directives and its type, leaving one Step with two explanations and another with none. dexcli refused to emit until each Step had exactly one.
Four controls, not a query builder: a keyword box, a status, a time window, and one exact attribute value. Every one compiles to a filter the search endpoint accepts, so the UI cannot ask for a query the server will reject. The keyword box searches the Flow's one full-text Attribute and renders ONLY when the Flow declares one — the deterministic refund Flow has none, so it simply has no keyword box rather than a box that quietly matches nothing. Its placeholder names the Attribute it searches, and a line under the controls names all of them, both read from the contract rather than written as prose that drifts from it. It also says what is NOT searched: not run inputs, outputs or history. Time is relative — last hour, 24 hours, 7 days — because nobody remembers a timestamp, and it compiles to an absolute lower bound the index understands. Filtering stays server-side, so it still holds at ten thousand runs. The earlier client-side idea would have searched only the fifty rows already on screen. The filter rows now have one owner. useFlowSearch takes them rather than holding them, and they are recompiled on submit rather than on change, so a half-typed keyword is never a query and the rows the list was searched with stay stable while the next one is typed. Verified against three live runs: the domain fragment northwind.com narrows three to two, an exact amount in dollars finds one, Clear restores all three.
A refund now stops for a person twice, and the drawer says so: a strip of the Flow's human gates, each done, current or upcoming, with how many are left. Answering one advances to the next in place — no going back through Run history to find it. Derived rather than declared. A Step is human-actionable when something outside the Flow publishes to the Channel it waits on, which answeredBy already reads off the graph, so this needed no contract change and a third gate would need no UI change. Order is breadth-first from the start, because an upcoming gate has no execution to sort by and still has to appear in the right place. A gate a branch skipped reads done, not pending. Small refunds never reach the manager but still need their message confirmed, and leaving that gate "upcoming" forever would make the count a lie. Only one gate is ever open, so this reads as progress through a sequence and not a list of things that could be done now. The strip is absent for a Flow with one gate, where a progress indicator would be noise. THIS REVERSES PART OF AN EARLIER CHANGE. Taking an Action was made to close the drawer, which was right when one Action ended the run; with a second gate pending it threw away the thing the reader needed next. It now closes only when no Action remains. Verified on a live run: Approve moves the strip from current/upcoming to done/current, swaps the Actions to "Send as written" and "Rewrite and send", re-points the band, and leaves the drawer open.
Two Canvas regressions from the guided-action work. Edges were invisible. Auto-focusing the blocked Step reused the reader's selection path, so the dim pass hid 43 of 47 edges; and the stroke-width rule lost to @xyflow's own stylesheet at equal specificity, leaving every edge at the 1px default. Dimming is now opt-in per selection source, and the width rule is qualified by .pedge so it applies at all. Nodes were also too far apart. The nodesep/ranksep graph options are inert here because this layout only takes within-rank order from dagre, so the real lever is rankBase, now 44. Two overlap guards over the shipped refund graph pin the floor: both fail at 36.
…shot tuning Spacing was being tuned one screenshot at a time, so a constant that helped one graph could quietly hurt twelve others. `layoutQuality` states what a drawing has to achieve, gives each principle a metric, and the corpus test runs all 33 shipped Flow definitions in all four view modes against them. That measurement located the reported sprawl without being told where to look: the refund flow was the corpus worst on region waste. Its Failure region reached from the spine across an empty lane to the recovery gutter, because a row rectangle spanned members separated by nothing at all, and the merge pass only rejected unions that swallowed a foreign card. Regions are now contiguous. A gap that could hold another card is a hole, and stacked rows merge where side-by-side rectangles do not. Worst-case region waste falls 0.876 to 0.731 against a 0.493 floor set by padding and the label strip, and Failure becomes five tight regions instead of one sprawling band. Placement is unchanged: BillingFailedStep has three ordinary transitions into it, so the error gutter would have been a false claim about how it is reached.
Group cohesion joins the measured layout principles: a declared group should be one place, and a same-label region repeated at one rank with a lane between it is the shape a reader cannot explain. Guarded at zero across the corpus, and the guardrail bites — disabling the rule fails it on the refund flow. Cohesion is local and narrow. A STRUCTURAL TWIN moves: same group, same downstream target, adjacent rank. BillingFailedStep and SubscriptionFailedStep both catch a failure and both continue to DraftCustomerMessageStep, so only the reachability test had them in different lanes — one is also a guarded branch target. They now share the gutter with EmailFailedStep as one region. NotARefundStep and NonConvergenceStep are in that same group and stay on the spine, because they continue elsewhere many ranks earlier. Forcing them across would buy a tighter region with a much longer edge, which is the trade the short-edges limit exists to hold. The refund flow also comes out 380px narrower. Region waste moves 0.731 to 0.751 and mean edge span 5.30 to 5.69, both deliberate: cohesion is not free, and the limits are where it stops.
Two findings from the same drawing, one a real bug. REGION MERGING UNDER-CONVERGED. The greedy merge bounded its passes by the live `rects.length`, which shrinks on every merge while the counter rises, so it could only ever perform about half the merges available. The refund flow's Resolution group spans seven rows and stopped after four, shipping three regions where one was available. Bounded by the starting count, it is now one. LANES ARE ASSIGNED PER GROUP, not per Step. Reachability decides a Step's lane correctly and still drew Failure as five regions in three columns, because only three of its members are reached by failing — the other two are guarded branch targets that happen to be terminal. A group that is mostly error handling IS error handling, so all of it shares the recovery lane and Failure becomes one vertical strip. Majority, not presence, so one compensating Step cannot drag its whole phase aside; measured over the corpus, Failure is the only group that reaches the threshold. This replaces the narrower twin rule, which it subsumes. The tight-regions metric was wrong twice and is now measured against the cards. Area punished the shape a strip is meant to have. Uncovered width then charged every region for its own padding and label strip. It now reads the largest sideways hole in card widths: 0.65 for an ordinary gap between fan members, 1.09 with the merge guard removed, so the limit sits at 0.85 where the data put it. Both guardrails were broken on purpose to confirm they fail, and README.md records the loop, the metric traps, and the bugs this machinery has caught.
Regions come from `dex:group`, an FDG 2.0 directive, so the 31 schema-1.0 definitions in the corpus have none and pass `tight-regions` and `group-cohesion` vacuously. Real coverage for those two is 8 of 132 scenes, not the 33 flows the report implied, and the report now says so. The group-bearing definitions are pinned by name. Regenerated JSON that quietly lost its groups would otherwise leave every region principle green with nothing to measure, which is the failure mode that already hid stale metadata once. No analyzer change is needed. It errors unless every Step declares exactly one dex:group and rejects one group id carrying two labels, so the membership the layout now reads for lane assignment is total and unambiguous.
…closure Status and time are the two questions every Flow can answer whether or not it declared anything indexable, so they are the default view. An exact run id and one attribute comparison sit under "Advanced search", where choosing an operator does not tax a reader who only wanted today's failures. The operator picker is built from the index type rather than offered whole, so the UI cannot compose a query the server will reject: `contains` appears only for fulltext, ordering only for int, double and datetime, and an operator that becomes illegal when the Attribute changes falls back to that type's default. The value control follows the Attribute's value type, which restores the bool select and datetime input that the four-control version had dropped. Restores run-id lookup, which the four-control version lost. Searching for a known run is among the most common reasons to open this panel and there was no way to do it. Three surfaces now, because they are three acts: pick a Flow type, find runs, read the list. Stacked bare controls read as one long form and the list looked like a fourth field. `in` stays out. It needs a comma convention explained in placeholder text and a second value control as soon as anybody wants two ranges.
The scope sentence listed every indexed Attribute above every list. The Attribute picker inside Advanced search already names them, in the same words from the same contract, so the sentence restated the control below it. "Ask again" re-fetched with the filters last applied while Search re-fetches with the filters on screen. Two buttons for one request, and the pair could disagree: edit a control, press Ask again, and the list answers the old question while the form shows the new one. Search is always visible in the same panel, so it is the single trigger now. Stopping a run still refreshes the list. Removes what the deleted FilterBuilder and FlowListing left behind: `describeFilters` and its phrase table, `QUEUE_COPY.scope`, `unfilteredHint` and `onThisPage` were reachable only from one test, which is dead product code with a test holding it up. The liveness lines stay — a stale list still says so.
Adding a second human gate stopped four agentic e2e tests at a wait nobody answered, so each burned its full minute in WaitForFlow and reported only "context deadline exceeded". They now confirm the message before waiting for completion. Both gates publish through the same gate-request-key and the drafting Step mints a fresh one, so the helper waits for the first key that is not the approval key just answered rather than assuming an order. An unknown billing effect gives up at NonConvergenceStep without drafting a message, which is why that one case stayed green and why the table flags which cases message the customer. The visualizer test pinned the agentic Flow's Actions at two and its first Indexed Attribute positionally. The Action list now names all four. The Indexed Attributes are checked as a keyed map, because reading index 0 made an unrelated declaration able to break an assertion about case-status — which is exactly what happened when customer-email sorted ahead of it.
Collaborator
|
Reopened with the review feedback addressed. Since the close:
Also fixed along the way: Canvas edges were invisible because the CI is green (33 pass, 1 skipping). The description above is rewritten in full, including the gaps I deliberately left out — the role contract is not in this branch, so Inbox is still a run list rather than a participant view. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two modes for Dex Web v2, after a process has been developed: a semantic Run view for an admin, and an Inbox for a participant who has to act. Chat is not part of this — it was a mock in the prototype and Dex has not enabled it.
What the branch does
Run view — the primary admin view. A canvas of the flow with a right-hand drawer per run. Non-technical by default: each Step says what it does in the flow's own terms, sourced from
dex:explanationrather than from prose kept beside the code. Selecting a run with an open Action focuses the Step it is stopped at and opens the drawer; closing resets. Clicking a Step with no run selected still explains the Step, because a definition is a readable artifact on its own.Deep Dive — the v1 detailed view, nested rather than replaced. Its layout and information density are kept intact; only colour and typography are unified, in a
.v2-debugscoped layer. Reached from the run header, so v1's technical depth is one click away instead of gone.Inbox — the participant list. Shares one
RunListwith the Run view so the two cannot drift into two visual languages. The role contract is not in this branch: it still lists runs rather than "what needs me".Two human gates in the refund example.
RequestHumanApprovalStepand a newConfirmCustomerMessageStep, so multiple actionable Steps per run is exercised by a real flow rather than asserted.customer-emailandrefund-amountare now Indexed Attributes; amount is in dollars.Canvas layout is governed by measured principles.
layoutQuality.tsdeclares what a drawing must achieve, each with a metric and a limit, and the corpus test runs every shipped definition in all four view modes against them. This replaced tuning constants against one screenshot at a time.Review feedback addressed
1. Restore and simplify run search. Status and time are the default view — the two questions every Flow answers whether or not it declared anything indexable. An exact run id and one attribute comparison sit behind
Advanced search. The operator list is derived from the index type, so the UI cannot compose a query the server rejects:containsonly for fulltext, ordering only for int, double and datetime. The value control follows the Attribute's value type. No keyword search over payloads, inputs, outputs or history — only declared Indexed Attributes, named in the picker from the contract. No multi-row query builder.Checked against what
FilterBuilderhad before it was removed: operators, the bool select and the datetime input are restored, and run-id lookup is restored — it had been lost, and searching for a known run is among the most common reasons to open the panel.instays out; it needs a comma convention in placeholder text and a second value control as soon as anybody wants two ranges.2. Multiple human-actionable Steps per run. Derived from the graph, not declared: a Step is actionable when something outside the flow publishes to the Channel it waits on. A flow that grows a third gate grows a third entry with no UI change. The drawer shows a progress strip, defaults to the first, marks the current one, and advances to the next pending gate on completion instead of closing — closing was right when one Action ended the run, but with another gate pending the reader should not have to find it again. Nothing requires navigating back into run history.
3. Existing Dex interaction model preserved. The Run view, Process Canvas and Inbox are not redesigned. Changes are additive or fixes to what was already there.
Fixes found along the way
stroke-widthrule lost to@xyflow/react's own stylesheet at equal specificity, so every edge sat at the 1px library default — the earlier 1.5px had always been a no-op. Auto-focusing a Step also reused the reader's selection path and dimmed 43 of 47 edges.rects.length, which shrinks on every merge while the counter rises. A group spanning seven rows stopped after four merges and shipped three regions where one was available. This affected every flow, not just the one on screen.examples/go/README.md.describeFilters,QUEUE_COPY.scope,unfilteredHintandonThisPagewere reachable only from one test, which is why nothing flagged them.Verification
web: 208 tests,npm run checkclean.docs:npm run checkclean — both locales build, 156 indexable routes, 7 redirects, SEO audit passed.make copyright-checkclean.amount >= 99999→ 0 rows,>= 0→ 5,email contains northwind→ 2,contains nosuchdomain→ 0,runId = adv-1→ 1.Known gaps, deliberately not in this branch
dex:field role:anddex:role-attribute, a role picker, and "needs my action" ordering. Without it Inbox is a run list, not a participant view.dex:groupis FDG 2.0 and the other 31 are 1.0 — sotight-regionsandgroup-cohesionare exercised by 8 of 132 scenes. Pinned by name so stale regenerated JSON cannot quietly empty them. Porting a second FDG 2.0 flow is the way to widen it.dex-web.mdxand its zh-Hans mirror are not updated for the two modes.