fix(client): make trigger-menu Enter an explicit no-op while refinement pends
Retained rows made arbitrate('enter') claim 'pick-highlighted' while
pick() silently declined the pending group, so the key vanished by
coincidence. Check the highlighted group's readiness like Tab does and
return 'consumed' deliberately; record the stale-while-revalidate menu
decision in an Agent Note.
This commit is contained in:
parent
5156226cb9
commit
6daed7c5aa
5 changed files with 88 additions and 0 deletions
|
|
@ -0,0 +1,6 @@
|
|||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md
|
||||
2026-08-28-trigger-menu-stale-while-revalidate.md: 12541ba3ec6ee82ac6c12da85f99c0d8e044b9e8
|
||||
2026-08-28-trigger-menu-stale-while-revalidate.zh.md: 69b3d1b6a304562e1bb1835b1de2f09f8f38c5a4
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Agent Note: The trigger menu keeps previous rows through refinement
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-28-trigger-menu-stale-while-revalidate.zh.md)
|
||||
|
||||
## 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.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Agent Note: The trigger menu keeps previous rows through refinement
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-28-trigger-menu-stale-while-revalidate.md) | 中文
|
||||
|
||||
## 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。
|
||||
|
|
@ -236,6 +236,11 @@ export class InputTriggerController {
|
|||
}
|
||||
case 'enter': {
|
||||
if (state.highlight === null) return 'pass'
|
||||
// Refinement keeps the previous rows and highlight visible while the
|
||||
// next fetch is pending; Enter then neither picks the stale row nor
|
||||
// falls through to submit — an explicit no-op until the group is ready.
|
||||
const group = state.groups.find(g => g.source === state.highlight?.source)
|
||||
if (group === undefined || group.status !== 'ready') return 'consumed'
|
||||
this.pick(state.highlight.source, state.highlight.index)
|
||||
return 'pick-highlighted'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -903,6 +903,29 @@ describe('arbitrate', () => {
|
|||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
})
|
||||
|
||||
it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => {
|
||||
const picks: string[] = []
|
||||
const cmd = deferredSource('/', 'command', {
|
||||
onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
|
||||
})
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
// Refinement: previous rows and highlight stay visible while the fetch pends.
|
||||
controller.track('/go', 3, { tier: 'plain' }, 2)
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
expect(controller.arbitrate('enter', false)).toBe('consumed')
|
||||
expect(picks).toHaveLength(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
// Settled: the same gesture picks again.
|
||||
cmd.pending[1]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
|
||||
expect(picks).toEqual(['goal'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSpace', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue