Composer selection surfaces mirror the composer's Enter and Escape keys
composer 的选择界面沿用 composer 的 Enter 与 Escape
Two selection surfaces inside the composer hold focus while they are open, and both left Tab to the browser. The popupSelect panel behind `/model` and `/permission` focuses its own search input and dismisses on pointer presses outside the card only. An unconsumed Tab fell through to native focus traversal and left the card open with its search input unfocused — a state no key clears. Its highlight also opened on the
English
Problem
Two selection surfaces inside the composer hold focus while they are open, and both left Tab to the browser.
The popupSelect panel behind /model and /permission focuses its own search input and dismisses on pointer presses outside the card only. An unconsumed Tab fell through to native focus traversal and left the card open with its search input unfocused — a state no key clears. Its highlight also opened on the topmost row while the option rows mark the session's current value wherever it sits in the provider-grouped list, so the first accept selected a row the user had not chosen instead of confirming the value in use.
The model seat's dropdown (ModelSelect, whose Effort row opens the reasoning-level pane) portals its card and moves real focus with the arrow keys. Focus stays on the trigger while the menu opens, and the walk read "focus is not on a row" as row zero before stepping, so the first forward step landed on the second row.
Decision
PopupSelectView settles on Enter or Tab and leaves on Escape or Shift+Tab: Enter and Tab both run select on the filtered highlight, Escape and Shift+Tab both dismiss and return focus to the composer through the conversation's own focus(), which rides Lexical so the caret comes back where the draft left it rather than at the start. That method is the one addition this decision makes to the SessionInput face: the shell needs the composer's own focus, not a DOM focus on its contenteditable, and the owner of the caret is the only party that can restore it. Opening the trigger menu from the composer's launcher button does the same before the menu opens: the menu is a combobox over the editor, so the button cannot hold the keyboard. ↑/↓ walk the filtered highlight (wrapping, scrolled into view) and ←/→ stay native to the search input. Every one of those keys is consumed, so a row list that is not ready yet swallows Tab as a no-op rather than releasing focus out of the card.
PopupSelectController parks the highlight on the row the loaded list marks as the current value, falling back to the top row when no row is marked or when the search retained across a retry hides it. Typing rebases the highlight to the top of the filtered rows, because a search asks for something other than the current value. Settling on a freshly opened panel therefore confirms the value in use.
ModelSelect keeps ↑/↓ as its focus walk over the rows of the shown pane (wrapping; a step taken while the trigger still holds focus enters at the near end). Tab settles: it activates the row the keyboard is on, and with focus still on the trigger it enters the menu at the row of the value in use instead. Escape and Shift+Tab leave a drilled pane first and otherwise close back to the trigger. A pane switch replaces the row that had focus, so each switch names where the keyboard lands: drilling focuses the row of the value in use (its checked row, or the first row when none is marked), and returning to the root pane focuses the cell that opened the pane left. Without that handoff the focus the unmounted row left on the page body sits outside the card's subtree, where its key handling never sees a keystroke.
Mouse presses on the ModelSelect trigger or portaled menu buttons prevent the browser’s default focus transfer: WebKit can otherwise blur the focused row and close the menu before click. Native click still owns selection and cancellation. Opening the menu or closing it through the trigger puts focus on the trigger. Selection requests return focus there before disabling the option rows, so a rejected request leaves keyboard navigation available.
Menu, the shared anchored dropdown behind the composer's permission seat, the preset chip, file cards, and the settings rows, carries the same pairing: while its list is open, Tab settles the focused row — from the anchor's button, Tab enters the list instead — and Escape or Shift+Tab close it and return focus to the anchor's first button. Only a keyboard already on the anchor or inside the list is intercepted, so Tab presses elsewhere on the page keep the browser's traversal even while a menu is open. The row's focus fill is the row's own indication: the browser's default ring would double it, and the fill is the same one the row shows under the pointer, so the visual language stays one per state. Selecting a row hands the keyboard back to the anchor unless the owner moved it itself — a card that focuses its own preview keeps it. The arrows, Home, and End walk the list whether or not autoFocus is set — that option now decides only whether opening focuses the first row — the row the keyboard is on carries the same fill a hovered row gets, and closing on Escape or Shift+Tab returns focus to the anchor whenever the menu held it.
A dismissed menu stays dismissed: InputTriggerController records the identity of the hit the user closed — Escape or Shift+Tab, a pointer dismissal, or a settling pick — and a re-track of that same token with the same query leaves the menu closed, so restoring the caret after a dismissal (or a settled command closing the surface it opened) cannot revive it. A new query or another token re-arms it.
The row contracts are unchanged. Producers already mark their current row active, and neither surface gains a drill verb.
Alternatives considered
Tab walks the rows. Tab as a second ↓ gives these surfaces no settle gesture on the key the composer already uses for one, and leaves Shift+Tab meaning "walk backwards" instead of the Escape the neighbouring key mirrors. The composer's own pairing — Enter/Tab settle, Escape/Shift+Tab leave — was chosen instead.
Let the panel's Tab fall through to native traversal. The card's dismissal reacts to pointer presses alone, so the residue — open card, unfocused search input — stays until the next click.
Keep the panel's highlight on the top row. It reinstates the fault this decision removes: accepting on a freshly opened panel acts on a row nobody chose.
Rebase the panel highlight onto the current value after a search too. A search expresses interest in rows other than the current value; keeping the highlight on a row the query may have ranked last contradicts the query.
Open a drilled pane on its top row. It parks the keyboard on the topmost level rather than the one in use — the same fault the parked highlight removes — so an accept gesture made without looking switches the effort instead of confirming it.
Leave the seat's Tab native. The seat closes on blur when focus leaves the trigger and the card, so a Tab there was not a dead state. It lost because a surface whose keys mirror the composer's should not single out Tab: Tab settles there like Enter, and Shift+Tab leaves.
Verification
Seat browser evidence drives real Chromium and WebKit against the shipped bundles: both verify native mouse selection, cancellation, focus after rejected requests, and keyboard navigation. Each opens the drilled level pane, walks it with ↑, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. Seat keyboard tests cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the ↑/↓ walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. Popup view tests cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on ↑/↓. Trigger tests cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. Menu tests cover the arrow walk without autoFocus (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. Popup controller tests cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives /model or /permission yet.
Consequences
Accepting on a freshly opened panel confirms the value the session already uses instead of switching it, so Tab settles nothing surprising; a Tab on a walked highlight settles that row, exactly as Enter would. Leaving either surface is Escape or Shift+Tab, and both are consumed while a surface is open, so a keyboard user steps out with Escape or Shift+Tab rather than by tabbing away. The composer's trigger menu keeps its own Tab, which settles or drills the highlighted candidate — its drill verb has no other keyboard route — while Escape and Shift+Tab leave that menu without settling, so the exit gesture never consumes the draft. The same pairing reaches every Menu consumer at once, so the composer's permission seat, the preset chip, file cards, and the settings dropdowns all settle on Tab and leave on Shift+Tab. The keys are recorded in each owning module's documentation and README, and the trigger menu's keys stay in the ui-input-trigger README.
中文
问题
composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab 留给了浏览器。
/model 与 /permission 背后的 popupSelect 面板把焦点放在自己的搜索框上,且只响应卡片之外的指针按压。未被消费的 Tab 于是落到原生焦点遍历上,把卡片留在打开状态、搜索框已失焦——这是一个任何按键都清不掉的状态。它的高亮还总是落在首行,而选项行标记的会话当前值可能位于按 provider 分组的列表中的任意位置,于是第一次接受选中的是用户没有选择过的行,而不是确认正在使用的值。
模型位的下拉菜单(ModelSelect,「推理等级」行打开其面板)把卡片 portal 出去,并用方向键移动真实焦点。菜单打开期间焦点留在触发器上,而走位把「焦点不在任何行上」当作第 0 行再步进,于是第一次向下移动落在第二行上。
决策
PopupSelectView 用回车或 Tab 接受,用 Escape 或 Shift+Tab 离开:回车与 Tab 都对筛选后的高亮行执行 select,Escape 与 Shift+Tab 都关闭并通过对话自己的 focus() 把焦点还给 composer,该路径走 Lexical,因此光标回到草稿原来的位置,而不是开头。该方法是本决策对 SessionInput 面的唯一新增:外壳需要的是 composer 自己的焦点,而不是对其 contenteditable 的 DOM focus,而光标的拥有者是唯一能恢复它的一方。从 composer 的 launcher 按钮打开触发菜单时也在开菜单之前做同样的事:该菜单是挂在编辑器上的 combobox,按钮不能握着键盘。↑/↓ 在筛选后的高亮间移动(循环,滚动进视野),←/→ 仍归搜索框的原生光标。上述按键全部被消费,因此尚未就绪的行列表会把 Tab 当作 no-op 吞掉,而不是把焦点放出卡片。
PopupSelectController 把高亮停在已加载列表标记为当前值的那一行;没有行被标记,或重试时保留的搜索把该行过滤掉时,退回首行。输入搜索会把高亮重定到筛选结果的首行,因为搜索要找的正是当前值之外的东西。因此在刚打开的面板上接受即确认正在使用的值。
ModelSelect 的 ↑/↓ 仍是在所显示面板行间移动焦点(循环;焦点仍在触发器上时从近端进入)。Tab 执行接受:它激活键盘所在的那一行;焦点仍在触发器上时,改为进入菜单并把焦点放在正在使用的那一行。Escape 与 Shift+Tab 都先退出已下钻的面板,否则关闭并回到触发器。面板切换会替换持有焦点的那一行,因此每次切换都指明键盘的落点:下钻落在正在使用的值那一行(其选中行;没有标记行时为首行),返回根面板则落在打开该面板的那个格子上。没有这一步,已卸载的行留在页面 body 上的焦点就位于卡片自身子树之外,挂在那里的按键处理永远收不到键盘事件。
在 ModelSelect 触发按钮或 portal 菜单按钮上按下鼠标时,阻止浏览器的默认焦点转移:否则 WebKit 可能先让聚焦行失焦,并在 click 前关闭菜单。选择和取消仍由原生 click 决定。打开菜单或通过触发按钮关闭菜单时,焦点落在触发按钮上。选择请求在禁用选项行前将焦点交回该按钮,因此请求被拒绝后仍可使用键盘导航。
Menu——权限档位、预设芯片、文件卡与设置行背后的共享锚定下拉——沿用同一组配对:列表打开期间,Tab 选定聚焦行(键盘在锚点按钮上时,Tab 改为进入列表),Escape 或 Shift+Tab 关闭并把焦点还给锚点的第一个按钮。只拦截已经位于锚点或列表内的键盘,因此页面其它位置按下的 Tab 即使在菜单打开时也仍归浏览器。行的焦点填充就是行自己的焦点提示:浏览器默认轮廓会与之叠加,而这一填充与指针悬停时用的是同一个,视觉语言因此一态一义。选定一行会把键盘还给锚点——除非拥有者自己移动了焦点(例如文件卡把自己的预览按钮设为焦点)。↑/↓ 与 Home/End 无论是否设置 autoFocus 都在列表中走位——该选项如今只决定打开时是否聚焦首行——键盘所在的那一行使用与悬停行相同的填充(否则键盘走位看不出当前行),而 Escape 或 Shift+Tab 关闭时,只要键盘原本在菜单里就把焦点归还锚点。
被关闭的菜单保持关闭:InputTriggerController 记下用户关闭的那个命中(Escape/Shift+Tab、指针关闭或一次落定选定)的身份,同一 token 以同一查询重新 track 时菜单保持关闭,因此关闭后恢复光标、或已选定命令关闭它打开的界面,都无法把它召回来;新的查询或另一个 token 才会重新武装。
行契约不变。产出方本就标记自己的当前行 active,两个界面也都没有新增下钻动词。
备选方案
Tab 负责走位。 让 Tab 成为第二个 ↓,会让这两个界面在 composer 已经用来接受的按键上没有接受动作,并把 Shift+Tab 变成「反向走位」,而不是它相邻按键所镜像的 Escape。最终选择的是 composer 自己的配对:回车/Tab 接受,Escape/Shift+Tab 离开。
让面板的 Tab 落到原生遍历。 卡片的关闭只响应指针按压,残留状态——卡片打开、搜索框失焦——会一直留到下一次点击。
面板高亮继续停在首行。 这正是本决策要消除的缺陷:刚打开的面板上接受会作用于没有人选择的行。
搜索之后也把面板高亮重定到当前值。 搜索表达的是对当前值之外内容的兴趣;把高亮留在查询可能排到末尾的行上与查询相悖。
被下钻的面板落在首行。 它把键盘停在最上面的一档而不是正在使用的那一档——正是「高亮停在当前值」要消除的缺陷——于是没看就按下的接受动作会切换推理等级,而不是确认它。
模型位的 Tab 保持原生。 焦点离开触发器与卡片时模型位会因失焦关闭,所以那里的 Tab 并不是死状态。它被否决是因为:一个沿用 composer 按键含义的界面不应把 Tab 单独排除——那里的 Tab 与回车一样接受,Shift+Tab 则离开。
验证
模型位浏览器证据 用真实 Chromium 与 WebKit 驱动已发布的 bundle:两者都验证原生鼠标选择、取消、请求被拒绝后的焦点以及键盘导航。每种引擎都会打开已下钻的等级面板,用 ↑ 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。模型位键盘测试 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、↑/↓ 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。弹窗视图测试 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 ↑/↓ 的循环。触发流水线测试 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。Menu 测试 覆盖不带 autoFocus 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。弹窗控制器测试 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 /model 或 /permission。
影响
在刚打开的弹窗上接受确认的是会话正在使用的值,而不是切换它,因此 Tab 不会带来意外的结果;对已经走过位的高亮按 Tab 会选定那一行,与回车一致。离开这两个界面用 Escape 或 Shift+Tab,且界面打开期间它们都被消费,键盘用户因此是靠 Escape 或 Shift+Tab 退出,而不是 Tab 走开。composer 的触发菜单保留自己的 Tab:它确认或下钻高亮候选——该菜单的下钻动词没有其他键盘入口——而 Escape 与 Shift+Tab 则离开该菜单且不选定,退出动作因此永不消耗草稿。同一组配对一次性覆盖所有 Menu 使用方:composer 的权限档位、预设芯片、文件卡与设置里的下拉都以 Tab 选定、以 Shift+Tab 离开。按键记在各归属模块的说明与 README 中,触发菜单的按键则留在 ui-input-trigger 的 README。