DSH / Atlas
2026-08-28implementedbug-fixarchived 2026-09-04

The trigger menu keeps previous rows through refinement

The trigger menu keeps previous rows through refinement

Every keystroke inside an open `@`/`/` trigger menu launches a new candidates fetch. The menu reducer's `hit` case used to reseed the groups to pending-empty, so the list collapsed to a skeleton for the 100–460ms fetch round trip and repainted on every character — a visible flicker on each refinement keystroke (#3234).

English

Problem

Every keystroke inside an open @// trigger menu launches a new candidates fetch. The menu reducer's hit case used to reseed the groups to pending-empty, so the list collapsed to a skeleton for the 100–460ms fetch round trip and repainted on every character — a visible flicker on each refinement keystroke (#3234).

Decision

The reducer's hit case (core/menu.ts) now retains the previous query's rows and highlight, marking each group pending — stale-while-revalidate. Fresh opens (seedGroups) still start empty, so the first paint keeps its skeleton; allReadyEmpty still auto-closes after settle.

Stale rows are display-only. pick() requires the candidate's group to be ready, and the enter arbitration checks the highlighted group's status before picking: during the pending window Enter is an explicit no-op ('consumed') — it neither picks the stale row nor falls through to submit the draft. Tab already carried the same ready check for drilling.

Alternatives considered

Clear to a skeleton on every refinement. Rejected; this was the flickering status quo. The production chat frontend's conversation search does clear (results and active index reset per debounced query), which keeps its Enter trivially safe — but its list is in a dedicated dialog, whereas this menu repaints directly under the caret on every keystroke, where the flicker is what users reported.

Pass Enter through to submit during the pending window. Rejected. Before this change the window showed an empty skeleton, so Enter falling through to send was visually consistent; with retained rows the user is looking at a highlighted candidate, and sending the whole draft under it is a worse mis-fire than a few hundred milliseconds of dead key. The production search's pending-window Enter is likewise a no-op.

Queue the Enter and pick when the fetch settles. Rejected. Acting on a keypress against rows the user has not seen yet reintroduces the stale-pick race with extra timing machinery.

Consequences

Refinement keystrokes no longer flicker; the list content swaps in place when the fetch settles. The costs: Enter is dead for the pending window (pressing it again after settle picks normally), and rows are index-keyed, so a settle swaps DOM node content in place — pointer tests must wait for a stale-only row to disappear before clicking (reference-composer.e2e.ts polls folderx/ away). A pre-existing highlight blink during refinement remains open and is deferred to a follow-up.

中文

Problem

在已打开的 @// 触发菜单里,每个按键都会发起一次新的候选请求。菜单 reducer 的 hit 分支过去会把各组重置为 pending-空,于是列表在 100–460ms 的请求往返期间塌缩成骨架屏,每输入一个字符就重绘一次——细化查询时肉眼可见的闪烁(#3234)。

Decision

reducer 的 hit 分支(core/menu.ts)现在保留上一次查询的行和高亮,并把各组标记为 pending——即 stale-while-revalidate。首次打开(seedGroups)仍从空开始,首帧保持骨架屏;allReadyEmpty 仍在结算后自动关闭。

旧行仅用于显示。pick() 要求候选所在组为 ready,enter 仲裁在 pick 前检查高亮组的状态:pending 窗口内 Enter 是显式 no-op('consumed')——既不选中旧行,也不落到草稿发送。Tab 的下钻早已带有相同的 ready 检查。

Alternatives considered

每次细化都清空为骨架屏。 拒绝;这正是闪烁的现状。线上 chat 前端的会话搜索确实是清空(每次防抖查询重置结果和活动索引),其 Enter 因此天然安全——但那个列表在独立弹窗里,而本菜单直接在光标下随每个按键重绘,闪烁正是用户所报告的问题。

pending 窗口内让 Enter 透传到发送。 拒绝。改动前该窗口显示空骨架屏,Enter 落到发送在视觉上是自洽的;保留旧行后用户正看着一个高亮候选,此时把整条草稿发出去比几百毫秒的按键失效是更糟的误触。线上搜索在 pending 窗口的 Enter 同样是 no-op。

把 Enter 排队,请求结算后再选中。 拒绝。对用户尚未见到的行执行按键会重新引入选中旧数据的竞态,还额外增加时序机制。

Consequences

细化按键不再闪烁;请求结算时列表内容原位替换。代价:pending 窗口内 Enter 失效(结算后再按即正常选中);行按 index 作为 key,结算时 DOM 节点内容原位替换——指针类测试点击前必须等待仅旧查询匹配的行消失(reference-composer.e2e.ts 轮询 folderx/ 消失)。细化期间已存在的高亮闪动问题仍未解决,留待后续 PR。