Skip to content

Automations: fix command matching, and four editor/menu UX gaps - #74

Merged
rockyway merged 13 commits into
developfrom
feature/automation-matching-and-editor-ux
Sep 5, 2026
Merged

Automations: fix command matching, and four editor/menu UX gaps#74
rockyway merged 13 commits into
developfrom
feature/automation-matching-and-editor-ux

Conversation

@rockyway

@rockyway rockyway commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Tam's brief carried one P0 and four P1s.

P0 — command contains "claude" matched nothing, ever

The rule editor kept showing "Nothing to watch. No open terminal matches command contains "claude"" while Claude was visibly running in several terminals.

resolve() was not at fault. foreground_command_line descended from the terminal's shell to the deepest youngest descendant and read only that process's command line. Walking the real process table on the dev machine:

CLI pid=17160 name=claude.exe children=2
   DEEPEST-YOUNGEST descent lands on: bun.exe(46820) -> conhost.exe(7584)
CLI pid=56392 name=claude.exe children=3
   DEEPEST-YOUNGEST descent lands on: bun.exe(88204) -> conhost.exe(73556)

Every live claude.exe has children, so the string tested against "claude" was conhost's. Deterministic, not flaky — the agent is on the chain, just never at the end of it, because agents spawn helpers.

The tell was next door: get_foreground_agent_with_exe runs the identical descent but tests every level, which is why per-agent colour schemes could name Claude while targeting could not. Three copies of one walk; one answered the question.

There is now one descent — foreground_chain_from, a pure core taking (pid, parent) pairs so it is testable without a live process table (the precedent has_live_children already sets in that file), depth-capped and cycle-guarded because targeting walks it inside the mutex ProcSnapshot holds. All three callers are re-expressed on it. RosterRow.command_line: Option<String> became command_lines: Vec<String> rather than a joined string, so the field's name stays true.

This fixes the class: node, npm, codex — every needle naming a program that spawns helpers — were broken identically.

A regression the first version of that fix introduced, caught in review: reading the shell link is necessary (an idle pwsh matched pwsh before and must still), but both spawn paths append -NoExit -Command <PS_CWD_INTEGRATION> — ~300 characters of app-authored script containing CurrentDirectory, FileSystem, [Environment], prompt, try, catch. Left in, roughly thirty ordinary needles (dir, path, env, command) would have selected every PowerShell terminal in the app, and a rule with a send action would have typed into all of them. command_line_for now drops exactly that injected sequence — script compared against the constant, the two flags removed only as the tokens immediately preceding it — so a profile's own -Command survives whole.

P1 — the Watch Output card showed one clipped line

.au-nval was white-space: nowrap with an ellipsis inside a card fixed at 244×160. It clamps at two lines now and AU_NODE_H grows to 180. Every reader of that constant is derived; saved layouts store x/y only, so nothing persisted moves.

P1 — the terminal picker was unidentifiable

Rows carried no title at all while id, name and folder each ellipsed, so two terminals in similar folders looked identical — and shell/pid were fetched and shown nowhere. Rows now carry full text, and hovering opens a card with every roster field plus ~6 lines of that terminal's current screen via /snapshot (never /output, which replays a lossy ring through a fresh parser). Only the hovered row is fetched; a closed terminal says so rather than showing an empty box that reads as a broken fetch.

P1 — Automation is always available in the context menu

Both menu surfaces rendered nothing when nothing was armed, and the code said why — a justification that expired the moment the section gained something to do on an unarmed terminal. Rewritten in place, in the section and in its two callers.

Armed or not, both hosts now offer New automation for this terminal (opens the editor on a draft already pinned to it) and Add to an existing automation (only pinned-mode rules — a criterion rule ignores targetIds, so offering it would be an item that visibly does nothing).

P1 — predefined rules: flash, don't auto-enable

Per Tam's ruling, the safety property stays. Templates land disabled on purpose, save() can only move enabled toward off, and the toggle refuses while the draft is dirty — so a saved template did nothing with no cue that a step remained. After a save whose result is disabled and has no blocking problems, the Enable toggle pulses briefly. Never when it saved enabled, never when it saved disabled because it is blocked.

Review

Two independent audits ran before this opened. The spec audit found every acceptance criterion met, no scope creep, one stale comment. The architecture audit found 1 HIGH (the over-match above), 4 MEDIUM, 6 LOW and nine assertions that did not pin what their names claimed — all fixed in this round rather than one per resubmission. The MEDIUMs: a stale rule snapshot that could resurrect a deleted rule through an unconditional upsert; a re-render guarantee that was false for the exact case the feature serves (useArmedAutomations returns a stable EMPTY); an unevicted snapshot cache; and AU_NODE_H pinned by nothing.

Gates

bun run typecheck clean · bunx jest 3642 passed / 209 suites · cargo test --lib 762 passed (no Rust touched after the P0).

Every new guard was mutation-checked — including the one that restores the shipped bug (resolve reading only the last chain link) and the one that restores the over-match (no stripping at all). Each killed exactly the test that names it.

After Tam's GUI pass — five more asks, then a sixth

Tam ran the branch and asked for five changes to the terminal right-click menu, then one more after
running it again. All are in.

  1. "New automation for this terminal" now opens the editor already showing the pick. The menu
    hands over a rule pinned to that terminal, and the editor used to open it on an EMPTY canvas
    compared against itself — so the one decision that had been made was the one thing nothing on
    screen said, and Escape threw the pinned terminal away without a word. freshCanvas: boolean
    became CanvasOpening, because there were three ways of opening the editor and a flag has two.
  2. The row names the terminalNew automation for this terminal (tm-…).
  3. ⚡ before every rule name, in all four rule lists — armed and addable, each drawn once in the
    accordion and once in the flyout, not just the one in the screenshot.
  4. Automation moved into the Command History / Snippets group.
  5. A three-second dwell before any context-menu tooltip appears, so sweeping down a menu no
    longer drags a trail of yellow boxes behind the pointer.
  6. A picked TEMPLATE opens unsaved and asks before closing — the same defect as (1), one card to
    the left: the gallery handed it over on the 'saved' opening, so the editor compared the template
    against itself and let Escape discard it silently. CanvasOpening gained a fourth value.

Review rounds

An internal 8-agent workflow (4 find lanes → 4 adversarial verify lanes) reviewed the GUI round:
26 findings, 23 confirmed, 3 refuted (A=4 · B=6 · C=8 · D=8), all fixed here.

The two largest were both in work that had just been called done, and both had the same shape — a
fix applied to the FILE in front of me rather than to the class. The dwell went into
Terminal/ContextMenu.tsx only, so identical rule rows delayed in the flyout and popped instantly
in the accordion one right-click away; and dwelt was keyed on row.id, which Command History
mints as history-${i} from a list it re-derives on every keystroke — so typing, with no pointer
event at all, handed an armed tooltip to a different command, reproducing the exact trail the
feature removes. Two more were my own tests asserting nothing: one dispatched a hover the scenario
it describes explicitly excludes, and both unmount tests counted a timer that a submenu-close timer
already satisfied.

One finding was refuted and deliberately not acted on: that the withheld title can never
produce a tooltip at all. The verifier could not establish it and caught the premise error behind
it, so the mechanism was not redesigned on a claim nobody could prove. It remains the one thing only
a real window can settle.

Three PRE-EXISTING defects were confirmed and deliberately left out of scope — none introduced
here, each wanting its own commit: AutomationEditor.save sends draft.rule rather than
ruleFromDraft(draft), so a dragged card is never written and the draft stays dirty for ever;
"Open the full log" unmounts the editor with no unsaved-changes guard, at three sites; and the
expanded accordion runs off the right edge (pre-existing, though the new (tm-…) is the first thing
cut). Recorded in termflow-fabric/docs/plan/031 under Open (after round 5).

Mutation testing

Every new guard was reverted individually and its suites re-run — including the ones that restore
the shipped bugs (resolve reading only the last chain link; no -Command stripping at all).

The later rounds ran 29 more mutants. Five survived a first pass on the tooltip work and four on
the round-5 work, each revealing a real hole rather than a cosmetic one — the clearest being that
both unmount tests were interchangeable, and that all three instantTitles branches for flyout rows
were unreachable by any test. A sixth pass on the template opening found that giving the BLANK card
the 'template' opening passed everything, because a blank rule's template-baseline works out
identical to itself and only the canvas differs. All closed; final state is every mutant killed.

Not tested here

The GUI. Tam tests that himself — worktree termflow-core/.claude/wt-automations.

…nd chain

A rule targeting `command contains "claude"` armed nothing, on every tick.
`foreground_command_line` walked from the terminal's shell to the deepest
youngest descendant and read only THAT process's command line. Measured on a
real machine: every live `claude.exe` has 2-3 children, so the chain is
`pwsh -> claude.exe -> bun.exe -> conhost.exe` and the string tested against
"claude" was conhost's. Deterministic, not flaky — the agent is on the chain,
just never at the end of it, because agents spawn helpers.

The same defect hit every needle naming a program that spawns children:
`node`, `npm`, `codex`. The tell was next door — `get_foreground_agent_with_exe`
runs the identical descent but tests every level, which is why per-agent colour
schemes could name Claude while targeting could not.

One descent now, expressed once:

- `foreground_chain_from` is the pure core, taking `(pid, parent)` pairs so the
  walk is testable without a live process table — the precedent
  `has_live_children` already sets in this file. Depth-capped and cycle-guarded,
  because targeting walks it inside the mutex `ProcSnapshot` holds.
- `get_foreground_process_info` and `get_foreground_agent_with_exe` are both
  re-expressed on it. The latter was a third copy of the walk and the only one
  left without either guard, on a path that also runs behind `/api/processes`.
- `RosterRow.command_line: Option<String>` becomes `command_lines: Vec<String>`
  rather than a joined string, so the field's name and its doc stay true.

Reading the shell link is necessary — an idle `pwsh` matched `pwsh` before and
must still — but it hands the matcher a string the APP wrote: both spawn paths
append `-NoExit -Command <PS_CWD_INTEGRATION>`, ~300 characters containing
`CurrentDirectory`, `FileSystem`, `[Environment]`, `prompt`, `try`, `catch`.
Left in, ~30 ordinary needles (`dir`, `path`, `env`, `command`) would have
selected every PowerShell terminal in the app, and a rule with a send action
would have typed into all of them. `command_line_for` drops exactly that
injected sequence — the script compared against the constant, the two flags
removed only as the tokens immediately preceding it, so a profile's own
`-Command` survives whole.

Why nothing caught the original: the descent loop was never exercised against a
real parent/child relationship (the one live test ran against the test binary,
which its own comment notes has no children), every roster fixture hard-coded
`command_line: None`, and `CommandContains` had no integration coverage at all.
…text menu

Both menu surfaces rendered nothing when no rule was armed, and the code said
why: the section "is not an action on this terminal that is temporarily
unavailable, it is a list of rules that do not exist." That was true, and it
stopped being true the moment the section gained something to do on an unarmed
terminal. Those paragraphs are rewritten in place, not annotated — including in
the two callers, which still described the old behaviour.

Both hosts now offer, armed or not:

- New automation for this terminal — opens the editor on a `blankDraft()`
  already pinned to it. `automationEditorHost` grew a second request shape for
  this: there is no id for `GlobalAutomationEditor` to resolve, so the menu
  hands over the whole draft. Same single-editor refusal as the id path, and the
  draft lands `enabled: false` like every other new rule.
- Add to an existing automation — only `targetMode: 'pinned'` rules, because a
  criterion rule ignores `targetIds` and offering it would be an item that
  visibly does nothing. Rules already watching this terminal are skipped for the
  same reason.

`armedMenuLabel(0)` is `'Automations'`, uncounted — one shared function, so the
accordion and the flyout parent cannot be renamed apart.

Two things the write path got wrong first time:

- A rejected `saveAutomation` was swallowed by `void addTerminalToRule(…)`: the
  user clicked, the menu closed, and the terminal was simply never watched. It
  toasts now.
- It wrote the whole rule as captured when the menu was ASSEMBLED. Since
  `save_rule_as_of` is an unconditional upsert with no version token, a rule
  deleted in another window came back from the dead. The id is re-resolved
  against the live rules at click time and bails audibly if it is gone —
  which is exactly why `openAutomationEditorFor` resolves ids rather than
  holding rule objects.

The accordion also read `getAutomationRules()` bare, under a comment claiming it
re-rendered on every store change. It does not: `useArmedAutomations` returns a
stable `EMPTY` for an unarmed terminal, so `emit()` changes no identity — and
unarmed is precisely the case this whole change serves. It subscribes now.
…the toggle

Three gaps in the rule editor, all about telling the user something the screen
was withholding.

**The Watch Output card showed one clipped line.** `.au-nval` was
`white-space: nowrap` with an ellipsis inside a card fixed at 244x160, so
`command contains "claude" · 3 now` became `command contain…`. It clamps at two
lines now and `AU_NODE_H` grows to 180 to fit. Every reader of that constant is
derived (`AuNode`, `portAnchor`, canvas fit bounds, palette drag) and saved
layouts store x/y only, so nothing persisted moves.

**The terminal picker was unidentifiable.** Rows carried no `title` at all while
id, name and folder each ellipsed, so two terminals in similar folders looked
identical — and `shell` and `pid` were fetched and shown nowhere. Rows now carry
full text, and hovering opens a card with every field the roster has plus ~6
lines of that terminal's CURRENT screen, read through `/snapshot` (never
`/output`, which replays a lossy ring through a fresh parser and is not what the
user is looking at). Only the hovered row is fetched; a closed terminal says so
rather than showing an empty box that reads as a broken fetch. The card portals
out with its own cache, because `CanvasMode` evicts the shared singleton every
frame it is open.

**A saved template did nothing, silently.** Templates land disabled on purpose,
`save()` can only ever move `enabled` toward off, and the toggle refuses while
the draft is dirty — so picking a template, editing and saving left the user
with no cue that a step remained. Per Tam, the safety property stays and the cue
is added: after a save whose result is disabled and has no blocking problems,
the Enable toggle pulses briefly. Never when it saved enabled, and never when it
saved disabled BECAUSE it is blocked — pointing at a control that will refuse
you is worse than silence.

The hover card's guards earn their tests: the one that matters is not a leaked
timer (three `cancelled` checks already close that) but a slow row resolving
after the pointer has moved on, painting the previous terminal's screen under
the next terminal's name — the exact mistake a card for telling terminals apart
must not make. `AU_NODE_H` is pinned by deriving the required height from the
stylesheet's own box terms rather than restating 180, which would have written
the same magic number twice and pinned nothing.
…a preview that reads the grid

Five findings from the external review of this branch.

**Resurrection was still reachable (blocker).** "Add to an existing automation" sent the whole
rule object back through `save_automation`, an unconditional upsert whose `None` arm INSERTs — so
a rule deleted in another window came back the moment a stale row was clicked. The first fix
re-resolved the rule id against the renderer's cached list and claimed in a comment that this
closed resurrection outright. It does not: `automation:changed` starts a refresh nobody awaits,
and even a fresh cache leaves the delete free to commit between the read and the write. A read
that DECIDES a write has to happen on that write's own transaction.

So it does. `AutomationStore::add_target_to_rule` evaluates existence and appends in one SQLite
transaction and answers `Ok(false)` — nothing written, no rule row and no orphan target row — when
the rule is gone; `add_automation_target` is its command, and the menu sends ids instead of a rule.
The clobber goes with it: a payload that carries no rule fields has nothing to overwrite a
concurrent edit WITH. The false comment is rewritten where it stood.

**The hover card's preview.** It held the screen without the id it was read from, so a row change
painted terminal A's screen under terminal B's name for one commit — the exact mis-attribution the
card exists to prevent, on the one gesture that produces it every time. Screen and process id are
now one value, discarded in render rather than corrected by an effect.

Its anchor refreshed only on the row's own `mousemove`, and the comment there argued a
scroll/resize listener was unnecessary. A wheel over a stationary pointer fires no `mousemove`, and
neither does a resize, so both of the two ways a row moves on its own were the uncovered ones.

And it stripped escapes from `/snapshot` — a replay stream whose cursor sequences CARRY the column
layout, so stripping collapses it. This repo already documented that, in `screen_text`'s own
comment and in a passing test, one file away. New `GET /api/terminals/:id/screen` serves the
authoritative grid render; nothing is left to strip.

**A test that asserted both callbacks fired but never their order**, under a name promising "closes
the menu first". `onDismiss` now records whether the editor had opened yet.

Every new guard mutation-checked: the listener, the derivation, the failure stamp, the `false`
answer, and the dismiss order all fail when reverted. `Canvas/NodeSnapshot.tsx` has the
escape-stripping defect too — pre-existing, out of scope here, recorded in plan 031.

typecheck clean · jest 3591/209 · cargo 767
…, and a hover that outlived its row

The remediation workflow's own mutation pass, run against the guards `79c4b0b`
shipped, killed two of them:

- `previewLines` strips ANSI again **survived**. Against `/screen`'s plain text
  `stripAnsi` is a no-op, so a test built on plain fixtures cannot see the mutant
  at all. The test now feeds it a real replay blob, where stripping is a loss.
- Removing the `cur.el !== hoveredEl` staleness guard **survived**. The two
  re-anchor tests pin that a scroll re-anchors, never that a scroll belonging to
  the row the pointer just left is ignored.

Both were mutants I considered and skipped as weak. They were the two that were
hollow.

And a defect neither the review nor I had: **a hover outlives its row.** Every
other exit from a hover is a pointer gesture, and the roster poll dropping a
closed terminal is not one — no `mouseleave` comes for an element React has
unmounted. The scroll listener stayed armed against a detached node, reading a
rect of zeroes; `evictPreviewsOutside` is gated on `hovered === null`, so the
preview cache stopped being evicted for as long as the phantom hover stood; and
`hovered.id` resolves against the current roster every render, so a terminal
returning under the same id — session restore's exact behaviour — re-opened its
card at an anchor captured before it left.

The `false` branch of the target add now returns rather than re-indexing:
nothing was committed, so there is nothing new to read, and the delete behind it
is already emitting the `automation:changed` that refetches this window's list.

typecheck clean · jest 3594/209 · cargo 767 (unchanged; renderer-only)
…trusting an ordering argument that measurement refutes

Adversarial verification of the last round returned four open verdicts. None of
the tests were vacuous; all four holes were real.

**The resurrection class survived in Settings.** "Forget this terminal" and the
verbose switch still read a rule from a possibly-stale list, mutated it, and
wrote the whole object back through the unconditional upsert — the mirror of the
gesture already fixed, and reachable from a logging toggle.
`remove_target_from_rule` and `set_verbose_until` join `add_target_to_rule`:
decide and write on one transaction, report a rule that is gone. Verbose is a
logging gate the engine never reads back, so its command deliberately does not
`reload`, and a test asserts it must not. All four source-derived censuses were
extended in this same change.

**The menu-ordering fix rested on a false premise, and I reverted it.** The
claim was that dismissing before a row's action stops a modal mounting under a
live menu. Probing the real component — counting live document listeners from
inside a row's `onSelect` — shows the menu still mounted with both handlers
installed either way round, because the dismissal is a queued setState React
does not flush until the end of the discrete event. The reorder bought nothing
and cost something: `TerminalDisplay`'s close callback refocuses the terminal,
and running it before "Add New Snippet" sets its guard ref fired `engine.focus()`
in the gap before the dialog mounted — under DECSET 1004, a spurious focus-in/
focus-out pair written to the PTY on every click, visible to any TUI that redraws
on focus.

The one real defect the reorder was reaching for — a throwing action stranding
the menu on screen — is closed by making the dismissal unconditional rather than
by moving it: `try { onSelect } finally { close }`. Production order restored,
every claim about ordering rewritten to what measurement supports.

**A hover card anchored to where its row used to be.** The anchor effect was
keyed on the hovered element, and a row moves for a third reason that changes no
element and fires no event: the roster poll dropping a terminal above it, or one
keystroke in the filter box. Same DOM node, no listener re-arms, pointer never
moves — a settled wrong anchor until the user moves the pointer, on a card whose
only job is saying which row this is.

**And the payload test spoke for a helper, not for the wire.** `screen_body`'s
keys were pinned; nothing required the handler to go through it. An inline
`json!({"text": ...})` left every screen test green, and the renderer's
`typeof body?.screen === 'string'` guard turns a missing key into a card stuck on
"Reading its screen…" for ever with nothing reporting a failure.

Every guard here mutation-checked by reverting it: the effect deps, the
`finally`, the wire contract, and the two ordering oracles.

typecheck clean · jest 3605/209 · cargo 776
…give the menu a group, an id and a dwell

Five things Tam asked for after taking PR #74 for a run.

**"New automation for this terminal" now shows what it decided for you.** The menu
hands the editor a rule that already pins the terminal that was right-clicked, and
the editor opened it on the gallery's blank canvas — so the one decision that HAD
been made was the one thing nothing on screen said. The palette drew its "Start
with Watch output" hint for a step that was already configured, and Escape threw
the pinned terminal away without a prompt, because a draft compared against itself
reads clean.

`freshCanvas: boolean` is now `opening: CanvasOpening` — three openings, and a flag
could only ever name two of them. `'seeded'` draws `monitor` and selects it, so the
inspector opens on *Watch output* with the terminal already ticked, and its dirty
BASELINE is the same rule with the seeded pick removed. That is what makes the
prompt honest rather than merely loud: it names the pinned terminal as the unsaved
work, clears itself on save, and does not nag a user who unticks that terminal and
changes nothing else.

**The row names its terminal** — `New automation for this terminal (tm-…)`. Three
hosts draw that row over three different things, and a window with several panes
open offers the identical sentence in each; the id is the one string that says
which, and it is the same id the picker ticks and the log prints.

**A rule offered by name carries the automation glyph.** Four lists, not the one in
the screenshot: armed rules and addable rules, each drawn twice because the section
feeds an accordion and a flyout. In the accordion the icon needs a wrapper —
`.au-menu-rule` is a column, so a bare sibling would stack above the name rather
than sit before it.

**Automation joins Command History and Snippets.** It sat among Copy/Paste on the
reasoning that it acts on the terminal, which is true of everything in that half of
the menu, so it never picked a group. The three flyouts are one group now, and the
placement test pins the SEPARATORS as well as the order — "in the group" is a claim
about the edges, and relative order alone is satisfied by a group of one.

**Menu tooltips wait three seconds.** Sweeping down a freshly-opened menu to the row
you were aiming at dragged a trail of native tooltips along behind the pointer. A
native delay cannot be changed, but whether there is anything to show can, so the
`title` attribute is withheld until the pointer has rested — no second tooltip
surface with its own positioning, edge-flipping and theming to answer for. Disabled
rows keep theirs unconditionally: they dispatch no mouse events, so a delayed title
would never arrive, and theirs is the tooltip that carries the most.

Every new guard was mutation-checked by reverting it. Five of the tooltip mutants
survived the first pass and are the reason four more tests exist: the two clears
covered each other on every path a sweep takes, a disabled ROW had the rule but no
test, the dwell's own value was invisible to tests that tick the constant, and the
unmount test watched for a React 18 warning that no longer exists — so it now counts
the pending timer instead. The submenu-close test one screen above it was vacuous
for the same reason and is fixed with it.
`items` is rebuilt by the owner on every one of ITS renders while this menu stays
mounted across them. `TerminalDisplay` re-renders with the menu open whenever agent
detection settles, and that inserts a "Color scheme for …" item ABOVE Copy, shifting
every index below it. A dwelt tooltip keyed on position was then handed to whatever
had slid into that slot — the wrong sentence, on a right-looking row, for the rest of
the dwell.

Two items sharing a label share a key, which only puts the attribute on both; just
one of them can be under the pointer, so nothing shows twice. The index stays as the
fallback for a labelless item.
The comment claimed it stops a stale tooltip being dragged along behind a sweep.
On every path a sweep takes, the row being left clears first and that line has
nothing to do — which is why mutation testing found both clears individually
removable with the suite green. Its exclusive path is a row leaving the DOM under a
stationary pointer, where React reports no mouseleave: filter the list and back, and
the row would return with its tooltip already armed.
…ard, and off for a picker

Four findings from an adversarial review of the dwell, three of them defects.

**A tooltip was handed to a row the pointer had never rested on.** `useTooltipDwell`
is cleared only by pointer events, and a flyout's search box is focused the moment
it opens — so re-filtering needs no pointer at all. Command History minted
`history-${i}`, a POSITION, from a list it re-derives on every keystroke: rest on
the top row for three seconds, then type, and a different command inherited the
armed key and popped instantly, once per keystroke. That is the trail the feature
removes, triggered by typing instead of by sweeping.

Both halves are needed and both are here. Row ids are content-derived (a djb2 hash,
because the id becomes a DOM id and commands contain spaces), so nothing can land on
a dwelt key; and the panel resets the dwell when its row set changes, so a row that
SURVIVES a filter does not keep an armed title either. The test that was supposed to
cover this dispatched a hover the scenario it describes explicitly excludes, and so
asserted only that a fresh dwell must be earned — it now asserts the remount state
before any pointer event, and a second test uses the function form of `rows`, which
is what the real builder passes.

**Keyboard and screen-reader users lost the description entirely.** Flyout rows are
`tabIndex={-1}` with focus in the search box, so arrow keys fire no mouse event and
`titleFor` returned `undefined` for every row they ever visited — and for a snippet
or a history command the `title` is the only copy of the full text the label
ellipses. The arrowed-to row and a focused host item keep their titles. "Is the
active row" could not stand in for "was arrowed to": `onMouseEnter` sets `activeIdx`
too, so reading it that way would have handed the hovered row its title instantly
and deleted the feature. Hence an explicit `keyboardNav` mode, with a paired test.

**The path picker is not a sweep surface.** Its labels strip the shared base
directory so three same-named files read as short tails, and the full path lives in
`title` — the tooltip is the menu's content, not an explanation of it. A new
`instantTitles` prop exempts it.

**And the hook moved to `hooks/`.** Scoped to `Terminal/ContextMenu` it delayed the
flyout's automation rows while the accordion drew the identical rules — same names,
same glyph, one inch away in the pane menu — with instant tooltips.
`AutomationMenuSection` may not import from `Terminal/ContextMenu` at runtime (its
stylesheet would follow into two hosts that do not use it), so the shared thing has
to live where neither owns it. Wiring the accordion is the next commit.

Both unmount tests were vacuous and interchangeable: `getTimerCount() > 0` was
satisfied by the 260ms submenu-close timer, so the dwell test stayed green against a
hook with no timer in it, and each went red under the other's bug. Each now isolates
its own subject.
…seeded editor opens on

Round-4 review follow-up (26 findings, 23 confirmed, 3 refuted). The dwell had
been applied per-FILE rather than per-class: the same rule rows delayed in the
terminal menu's flyout and popped instantly in the pane-title accordion, one
right-click apart.

- The keyboard exemption moves into `useTooltipDwell` as `onFocus`/`onBlur`. It
  was a private `useState` in `ContextMenu` for one round; three more call sites
  arrived behind it, and a per-site copy of an accessibility exemption is a
  per-site chance to ship without it. `onLeave` and `reset` deliberately leave
  focus alone, so a pointer sweeping across a focused control cannot take a
  keyboard user's description away.
- Both accordion rule rows and `PaneContextMenu`'s Mute — the whole of the
  enabled-and-titled class outside `ContextMenu` — now dwell. Disabled items
  keep their titles unconditionally: they dispatch no mouse events, so a dwell
  over one can never be measured.
- The bolt is one exported constant (`AUTOMATION_GLYPH`) rather than four
  declarations, which is what "stated once" already claimed and was not.
- The seeded tests pin `draft.rule` absolutely. A mutant rewriting `targetMode`
  to 'rule' passed all 369 automation tests while reproducing the exact defect
  the seeded opening exists to fix.
- The dirty baseline is asserted from the unseeded starting point rather than
  from `draft.rule`; the old form restated the implementation's own formula, so
  the promise its comment made was one it could not keep.

Three claims the code does not support, corrected in place: "back to an
untouched blank rule" (the baseline keeps `targetMode: 'pinned'`, which
`problems()` still blocks on `targets.empty` — now asserted), the `min-width: 0`
on `.au-menu-rule-head` (a no-op on a cross-axis flex item, and it never capped
the menu), and "the mark for an automation" (the WebGL toggle carries the same
bolt in the same menu). The icon tests assert ORDER, not presence, and the
accordion label is swept over two terminals.

23 mutants run, 23 killed. Four survived the first pass and needed new tests:
`onEnter`'s own clear (every sweep path fires `onMouseLeave` first, and
`reset()` covers only query-driven row changes), and all three `instantTitles`
branches for flyout rows, which nothing reached because the picker that
motivated the prop is a flat menu.

typecheck clean; jest 3636 / 209 suites.
Both mine, both from the previous commit.

- `onEnter`'s clear said `reset()` "covers the same shape from the list's side".
  It covers only part of it: `reset()`'s one caller runs on a change of `query`,
  so a row set that changes because the DATA under it changed never reaches it.
  That gap is exactly why this line survived the mutation sweep until a test was
  written for it, so the comment now says which half is left.
- `onBlur`'s keyed clear was justified by React running the new control's
  `onFocus` before the old one's `onBlur`. Backwards: `focusout` fires before
  `focusin`, so a bare clear would work today. The keyed form is still right —
  it just buys order-independence, not a fix for a live bug.
…first

Tam: "click New automation -> select predefined template -> it should become
Unsaved, when user close it should show confirmation".

The gallery handed a picked template to the editor on the 'saved' opening, whose
baseline is the rule itself — so it read clean, the "unsaved" chip never showed,
and Escape threw away the template the user had just chosen without a word. That
is the 'seeded' defect again, one card to the left: a choice had been made and
nothing on the way out said so.

`CanvasOpening` gains 'template'. It draws like 'saved', because a template IS a
complete rule, and its dirty baseline is the blank rule the gallery was showing
BEFORE the click — which makes the whole template the unsaved work, because that
is what it is. The baseline carries the resolved layout so that arrangement alone
can never be what makes a fresh template read dirty.

The panel's `View` carries the opening instead of a `fresh: boolean`. That flag
named only the blank card, and this is the third answer it cannot give: an
existing rule opens clean, the blank card opens clean on an empty canvas, and a
template opens dirty on a full one. Same reason `freshCanvas` stopped being a
boolean two rounds ago.

No new dialog machinery — `requestClose` already asks when `isDirty`, so the
confirmation follows from the baseline being right.

6 mutants on the new behaviour, 6 killed. One survived first: giving the BLANK
card the 'template' opening too, because a blank rule's template-baseline works
out identical to itself, so it still reads clean and only the CANVAS differs.
The blank-card test now pins the empty canvas, not just the absence of a prompt.

typecheck clean; jest 3642 / 209 suites.
@rockyway
rockyway merged commit 4feede6 into develop Sep 5, 2026
5 checks passed
@rockyway
rockyway deleted the feature/automation-matching-and-editor-ux branch September 5, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants