feat(client): localize composer hints and rework input command interaction

Unify the /plan claimed hint with the plan placeholder through a locale
namespace, localize slash menu group titles, replace the PermissionSelect
native select with the Menu primitive, add a goal pause verb chain, clamp
anchored popups to the viewport with scroll-into-view and outside-dismiss,
and fix onPasteUpgrade insertedRange to account for the chip trailing gap.
This commit is contained in:
Yif
2026-07-29 19:46:48 +08:00
parent c451b63016
commit 45c9205caf
46 changed files with 623 additions and 225 deletions
@@ -13,8 +13,10 @@
display: flex;
flex-direction: column;
min-width: 220px;
/* Height cap: the 320px design maximum, clamped at runtime to the space
* above the composer (inline max-height set in PopupSelectView.tsx). */
max-height: 320px;
overflow-y: auto;
overflow: hidden;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -26,6 +28,13 @@
outline: none;
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
@@ -34,11 +43,11 @@
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
}
.rowActive {
background: var(--dsw-alias-fill-hover);
background: var(--dsw-alias-interactive-bg-hover);
}
.label {
@@ -50,19 +59,20 @@
.detail {
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.check {
display: inline-flex;
color: var(--dsw-alias-text-secondary);
flex: none;
color: var(--dsw-alias-label-primary);
}
.status {
padding: 8px;
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
padding: 8px 10px;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
.search {
@@ -72,7 +82,7 @@
border-radius: 8px;
background: transparent;
font-size: 13px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
outline: none;
}
@@ -97,6 +107,6 @@
border-radius: 6px;
background: transparent;
font-size: 12px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
@@ -3,19 +3,23 @@
* store into the conversation.input.overlay anchor. Unlike the slash menu
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
* inner search input takes focus, plain typing filters the loaded options
* locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to
* the composer, and ←→ keep the search input's native caret. Any pointer
* interaction outside the box dismisses (the click's own target takes
* focus). Closed state renders null; the overlay slot stays mounted.
* locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape
* dismisses back to the composer, and ←→ keep the search input's native
* caret. Any pointer interaction outside the box dismisses (the click's own
* target takes focus). Closed state renders null; the overlay slot stays
* mounted. The card height clamps to the space above the composer.
*/
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
/** Design cap on the card height (same MenuDropdown family as the slash menu). */
const MAX_HEIGHT = 320
/** Injected business face of the popupSelect overlay entry. */
export interface PopupSelectInjected {
/** The session's shell controller (state store + verbs; the view never touches the open-context type). */
@@ -34,6 +38,17 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
)
const cardRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
// The card is bottom-anchored above the composer; clamp the design cap to
// the space above it, re-measured on every store update.
const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state)
const active = state.open ? state.active : null
// The search input keeps focus while arrows move a virtual highlight, so
// the browser never scrolls the active row into view — do it here.
useEffect(() => {
if (active === null) return
cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active])
// Focus ownership: the search input grabs on open (the design's
// transient-layer rule), and ANY outside pointer interaction dismisses —
@@ -42,7 +57,6 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
// takes focus naturally, so no focusComposer here.
useEffect(() => {
if (!state.open) return
searchRef.current?.focus()
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
@@ -51,6 +65,11 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
if (state.open) searchRef.current?.focus()
}, [state.open])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
@@ -83,6 +102,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={`/${String(state.command)} options`}
onKeyDown={onKeyDown}
>
@@ -108,7 +128,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`}>
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}