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

View File

@@ -7,6 +7,7 @@
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -31,12 +32,25 @@ async function bench() {
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
scopeOf: (c: Context) => scopeOf(c),
})
return { ctx, slots }
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots, locale }
}
describe('apply', () => {
it('declares the sessions dependency (controller resolution reads the scope tree)', () => {
expect(inject).toEqual(['sessions'])
it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
expect(inject).toEqual(['sessions', 'locale'])
})
it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
const { ctx, locale } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const t = locale.bind('slash.menu')
expect(t('command')).toBe('命令')
locale.setLocale('en')
expect(t('skill')).toBe('Skills')
expect(t('subagent')).toBe('Subagents')
expect(t('loading')).toBe('Loading…')
})
it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => {
@@ -65,6 +79,8 @@ describe('apply', () => {
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
)
expect(injected.menu).toBe(controller.menu)
// The injected translator is the menu-namespace binding.
expect(injected.t('command')).toBe('命令')
// The pick face routes into the controller pipeline (closed menu → no-op).
injected.onPick('command', 0)
expect(controller.menu.getSnapshot().open).toBe(false)

View File

@@ -1,11 +1,13 @@
// @vitest-environment jsdom
/**
* MenuView rendering spec, props-direct (slot-parity doctrine): closed store
* renders null, groups render in roster order with pending rows as loading,
* pointer picks route (source, index) back without stealing focus, and the
* highlight is exposed through aria-activedescendant + aria-selected.
* renders null, groups render in roster order under localized title rows
* (unknown sources fall back to the raw name) with pending rows as loading,
* pointer picks route (source, index) back without stealing focus, the
* highlight is exposed through aria-activedescendant + aria-selected, and
* the list height clamps to the space above the composer.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -34,13 +36,35 @@ function openState(partial?: Partial<MenuState>): MenuState {
}
}
afterEach(cleanup)
// jsdom has no scrollIntoView; the view calls it on the highlighted option.
const scrollIntoView = vi.fn()
beforeEach(() => {
Element.prototype.scrollIntoView = scrollIntoView
scrollIntoView.mockClear()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
// Dictionary-backed fake mirroring the LocaleService key fallback (an
// unknown key comes back verbatim, so unknown sources show their raw name).
const DICT: Record<string, string> = { command: 'Commands', skill: 'Skills', loading: 'Loading…' }
const t = (key: string) => DICT[key] ?? key
function mount(state: MenuState) {
const menu = createSnapshotStore<MenuState>(state)
const onPick = vi.fn()
const view = render(<MenuView menu={menu} onPick={onPick} />)
return { menu, onPick, view }
const onDismiss = vi.fn()
const view = render(<MenuView menu={menu} onPick={onPick} onDismiss={onDismiss} t={t} />)
return { menu, onPick, onDismiss, view }
}
/** The non-interactive group title rows (role=presentation), in document order. */
function titles(container: HTMLElement): string[] {
return [...container.querySelectorAll('div[role="presentation"][data-source]')]
.map(el => el.textContent ?? '')
}
describe('MenuView', () => {
@@ -57,7 +81,19 @@ describe('MenuView', () => {
mount(openState())
const options = screen.getAllByRole('option')
expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
expect(screen.queryByText('Loading skill…')).not.toBeNull()
expect(screen.queryByText('Loading…')).not.toBeNull()
})
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
const { view } = mount(openState({
groups: [
{ source: 'command', status: 'ready', items: [{ name: 'goal' }] },
{ source: 'hollow', status: 'ready', items: [] },
{ source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
{ source: 'skill', status: 'pending', items: [] },
],
}))
expect(titles(view.container)).toEqual(['Commands', 'mystery', 'Skills'])
})
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
@@ -75,6 +111,37 @@ describe('MenuView', () => {
expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
})
it('scrolls the highlighted option into view when the highlight moves', () => {
const { menu } = mount(openState())
scrollIntoView.mockClear()
act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
const options = screen.getAllByRole('option')
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
})
it('caps the list height at the design maximum when the composer sits low enough', () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
mount(openState())
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
})
it('clamps the list height to the space above the composer minus the safe margin', () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
mount(openState())
expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
})
it('re-fits the height when the window resizes', () => {
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
rect.mockReturnValue({ bottom: 800 } as DOMRect)
mount(openState())
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
rect.mockReturnValue({ bottom: 100 } as DOMRect)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
})
it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
const { onPick } = mount(openState())
const options = screen.getAllByRole('option')