diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml new file mode 100644 index 0000000000..bc6b1e4a09 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md new file mode 100644 index 0000000000..12541ba3ec --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md new file mode 100644 index 0000000000..69b3d1b6a3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md @@ -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。 diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 5d3e1bf0b3..883a795775 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -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' } diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 43eeba0f64..929d1e81e9 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -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', () => {